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

107 lines
5.6 KiB
Markdown

[← 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)