1
0
Files
spring-auth-demo/docs/passkeys/06-the-bootstrap-problem.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

5.6 KiB

← 05 — The defaults · index · next: 07 — Failure modes

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

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:

$ 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.

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 and docs/output/pk-ceremony.txt:

{"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:

.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, 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 · index · next: 07 — Failure modes