[← 01 — Versions](01-versions.md) · [index](README.md) · next: [03 — The two ceremonies](03-the-two-ceremonies.md) # The minimum configuration ```java 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 ```java private WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations( PublicKeyCredentialUserEntityRepository userEntities, UserCredentialRepository userCredentials) { Optional 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`](../../passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java) 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`](../../passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java). ## Where the filters land From [`docs/output/pk-filters.txt`](../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](06-the-bootstrap-problem.md). `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](01-versions.md) · [index](README.md) · next: [03 — The two ceremonies](03-the-two-ceremonies.md)