1
0
Files
spring-auth-demo/docs/passkeys/02-minimum-configuration.md
Ankur Mhatre f6dd692177 Add passkeys project: WebAuthn ceremonies, a software authenticator and the one-time-token fallback
Fourth Maven project in the repository. Registration and authentication run end to
end with no browser and no hardware key: VirtualAuthenticator emits real CBOR
attestation objects and real ES256 assertion signatures, and tools/PasskeyCeremony.java
drives the live HTTP endpoints with them.

Profiles cover userVerification REQUIRED, DIRECT attestation, a disallowed origin and
JDBC persistence. Eleven doc chapters and twelve captured transcripts under docs/passkeys
and docs/output/pk-*.txt, all regenerated by passkeys/scripts/run-all.sh.
2026-08-25 23:00:27 +05:30

4.9 KiB

← 01 — Versions · index · next: 03 — The two ceremonies

The minimum configuration

http
    .authorizeHttpRequests((requests) -> requests.anyRequest().authenticated())
    .formLogin(Customizer.withDefaults())
    .webAuthn((webAuthn) -> webAuthn
        .rpId("localhost")
        .rpName("ankurm passkeys demo")
        .allowedOrigins("http://localhost:8080"));

Plus a UserDetailsService bean. Without one, WebAuthnConfigurer.configure throws IllegalStateException: Missing UserDetailsService Bean at startup — passkeys authenticate a credential, and Spring still needs somewhere to look up the authorities that go with the username the credential resolves to.

That is the whole thing. It gives you six endpoints and a working browser flow:

method path what it does
POST /webauthn/register/options issues a challenge and the creation options; requires an authenticated session
POST /webauthn/register verifies the attestation and stores a CredentialRecord
DELETE /webauthn/register/{id} removes a credential, guarded by CredentialRecordOwnerAuthorizationManager
GET /webauthn/register the built-in registration page
POST /webauthn/authenticate/options issues a challenge and the request options
POST /login/webauthn verifies the assertion and creates the session

rpId and allowedOrigins are two settings, not one

The relying party id is a domain. It is hashed into authenticator data and it scopes the credential: a passkey created for example.com will be offered on app.example.com, because the rpId must equal the origin's effective domain or be a registrable suffix of it.

The allowed origin is the exact scheme, host and port string the browser puts in client data. http://localhost:8080 and http://127.0.0.1:8080 are different origins even though they reach the same server, and localhost is not a registrable suffix of 127.0.0.1. This is the single most common way to get a flat 401 out of a configuration that looks correct.

localhost is special: browsers treat it as a secure context, so WebAuthn works over plain HTTP there and nowhere else. The first deployment to a real hostname needs TLS before the ceremony will start at all.

The bean that silently disables the DSL

private WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations(
        PublicKeyCredentialUserEntityRepository userEntities, UserCredentialRepository userCredentials) {
    Optional<WebAuthnRelyingPartyOperations> webauthnOperationsBean = getBeanOrNull(
            WebAuthnRelyingPartyOperations.class);
    String rpName = (this.rpName != null) ? this.rpName : this.rpId;
    return webauthnOperationsBean
        .orElseGet(() -> new Webauthn4JRelyingPartyOperations(userEntities, userCredentials,
                PublicKeyCredentialRpEntity.builder().id(this.rpId).name(rpName).build(), this.allowedOrigins));
}

WebAuthnConfigurer, Spring Security 7.1.1

If a WebAuthnRelyingPartyOperations bean exists, it is used as is. The rpId, rpName and allowedOrigins you set on the DSL are never read. That is not a bug, but it is a silent one: the configuration still compiles, still starts, and still points at whatever the bean was constructed with.

RelyingPartyConfig in this module exposes exactly such a bean under three profiles, which is why it repeats the rpId and origin rather than sharing them with SecurityConfig.

Where the filters land

From docs/output/pk-filters.txt, on the real running chain:

 5 CsrfFilter
 7 GenerateOneTimeTokenFilter
 8 UsernamePasswordAuthenticationFilter
 9 OneTimeTokenAuthenticationFilter
16 WebAuthnAuthenticationFilter
20 ExceptionTranslationFilter
21 PublicKeyCredentialCreationOptionsFilter
22 PublicKeyCredentialRequestOptionsFilter
23 AuthorizationFilter
24 WebAuthnRegistrationFilter
25 DefaultWebAuthnRegistrationPageGeneratingFilter

Note where the line falls. The two options filters sit before AuthorizationFilter; WebAuthnRegistrationFilter sits after it. So /webauthn/register/options is answered without an authorization check, and enforces its own requirement that the caller be authenticated — which it does by throwing. See 06 — The bootstrap problem.

CsrfFilter at position 5 applies to all of them. Every WebAuthn endpoint is a POST that changes server state (it stores a challenge), so every call needs a CSRF token. A front end that fetches options without one gets a 403 and no explanation.

← 01 — Versions · index · next: 03 — The two ceremonies