[← 05 — The defaults](05-defaults.md) · [index](README.md) · next: [07 — Failure modes](07-failure-modes.md) # The bootstrap problem A passkey cannot be a user's first credential. This is not a Spring Security limitation, it is the shape of the protocol, and it is the thing that turns “add passkeys” from an afternoon into a project. ## Where it is enforced ```java Authentication authentication = request.getAuthentication(); if (!this.trustResolver.isAuthenticated(authentication)) { throw new IllegalArgumentException("Authentication must be authenticated"); } ``` — `Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions` `AuthenticationTrustResolverImpl.isAuthenticated` returns false for null and for anonymous, so registration options are only issued to a session that already proved who it belongs to. And they have to be: the options object contains `user.id` and `user.name`, and the credential is bound to them. There is nobody to bind to until somebody has logged in. From [`docs/output/pk-bootstrap.txt`](../output/pk-bootstrap.txt): ``` $ POST /webauthn/register/options (anonymous) HTTP 400 (empty body) ``` Note the status. Not 401, not a redirect to the login page — a bare **400** with no body, because `PublicKeyCredentialCreationOptionsFilter` sits before `AuthorizationFilter` and the `IllegalArgumentException` surfaces as a bad request. A single-page front end whose session has expired will see a 400 from an endpoint that worked a minute ago, and nothing about that response says “log in again”. Handle it explicitly on the client. ## So what carries the user in? Something else always does. The realistic options, in rough order of how often they are the right answer: | route | good for | cost | |---|---|---| | **One-time token** by email or SMS | new signups, and recovery after a lost device | you own a delivery channel and its failure modes | | **Existing password** | adding passkeys to an application that already has users | the password stays, so the phishing surface stays | | **Federated login** (OIDC, social) | consumer products that already federate | the identity provider becomes your recovery story | | **Support desk** | enterprises with an existing identity-proofing process | expensive, and now the desk is the attack surface | This module uses the first, because Spring Security ships it: `oneTimeTokenLogin` is one line of DSL, and it covers both the way in and the way back. See [08 — The one-time-token fallback](08-one-time-token-fallback.md). ## The recovery half is the hard half Registration is a solved problem the moment the user is logged in. Recovery is not, and the awkward truth is that **your recovery path sets your real security level**. A passkey that cannot be phished, backed by an email magic link that can be, is an application whose security is that of the email account. The mitigations are all trade-offs, and none of them are free: - **Require two passkeys.** A phone and a laptop, or a phone and a hardware key. Excellent, and roughly half of users will not do it. - **Rate-limit and age-limit recovery.** A magic link that logs you in but will not register a new passkey for 24 hours turns an account takeover into a race you can notice. - **Notify on registration.** Any new credential produces a mail to every address on file. Cheap, and it is how takeovers actually get caught. - **Step up for sensitive actions.** Recovery gets you in; it does not get you a password change or a payout. Spring Security gives you the primitives for the last two: `AuthenticationSuccessHandler` for notification, and `FactorGrantedAuthority` for step-up. A session established by one-time token carries `FACTOR_OTT`; one established by a passkey carries `FACTOR_WEBAUTHN`. From [`docs/output/pk-ott.txt`](../output/pk-ott.txt) and [`docs/output/pk-ceremony.txt`](../output/pk-ceremony.txt): ```json {"name":"user","authenticationType":"OneTimeTokenAuthentication", "authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]} {"name":"user","authenticationType":"WebAuthnAuthentication", "authorities":["FactorGrantedAuthority [authority=FACTOR_WEBAUTHN, issuedAt=...]","ROLE_USER"]} ``` Those are ordinary authorities, so `hasAuthority("FACTOR_WEBAUTHN")` is all it takes to require a real passkey on an endpoint that a magic link should not reach: ```java .requestMatchers("/passkey-only").hasAuthority("FACTOR_WEBAUTHN") ``` What happens when the authority is missing is better than a 403. `WebAuthnConfigurer.init` registers `defaultDeniedHandlerForMissingAuthority(.., FactorGrantedAuthority.WEBAUTHN_AUTHORITY)` with a `LoginUrlAuthenticationEntryPoint("/login")`, so Spring Security turns "you are authenticated but not with a passkey" into a redirect that says which factor is missing. From [`docs/output/pk-step-up.txt`](../output/pk-step-up.txt), the same user in three sessions: ``` password session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing one-time-token session -> GET /passkey-only: HTTP 302, Location: /login?factor.type=webauthn&factor.reason=missing passkey session -> GET /passkey-only: HTTP 200 {"ok":"this endpoint required FACTOR_WEBAUTHN"} ``` Those query parameters are generated for you and are not mentioned on the passkeys reference page. A login page that reads `factor.reason=missing` can say “use your passkey for this” instead of “access denied”. [← 05 — The defaults](05-defaults.md) · [index](README.md) · next: [07 — Failure modes](07-failure-modes.md)