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)
diff --git a/docs/passkeys/03-the-two-ceremonies.md b/docs/passkeys/03-the-two-ceremonies.md
new file mode 100644
index 0000000..160c00d
--- /dev/null
+++ b/docs/passkeys/03-the-two-ceremonies.md
@@ -0,0 +1,97 @@
+[← 02 — The minimum configuration](02-minimum-configuration.md) · [index](README.md) · next: [04 — A software authenticator](04-virtual-authenticator.md)
+
+# The two ceremonies
+
+WebAuthn has exactly two flows, and both have the same shape: the server issues a challenge,
+the authenticator answers it, the server verifies the answer. Everything else is detail.
+
+## Registration
+
+```
+POST /webauthn/register/options (authenticated session + CSRF token required)
+ -> challenge, rp, user, pubKeyCredParams, authenticatorSelection, attestation, timeout
+ and the challenge is stored in the HttpSession
+
+navigator.credentials.create({ publicKey: options })
+ -> a key pair inside the authenticator, and an attestation object containing the public half
+
+POST /webauthn/register { publicKey: { credential: {...}, label: "..." } }
+ -> { "success": true }, and a CredentialRecord in the UserCredentialRepository
+```
+
+The real options object, from a live run
+([`docs/output/pk-ceremony.txt`](../output/pk-ceremony.txt)):
+
+```json
+{"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"},
+ "challenge":"8H0qrJXIL_StIdOetvSm31hEcEy0NMoN8xz67u4UwFM","excludeCredentials":[],
+ "extensions":{"credProps":true},
+ "pubKeyCredParams":[{"alg":-8,"type":"public-key"},{"alg":-7,"type":"public-key"},{"alg":-257,"type":"public-key"}],
+ "rp":{"id":"localhost","name":"ankurm passkeys demo"},"timeout":300000,
+ "user":{"name":"user","id":"9RZ4HDuLE38GIFoTpth0hyAooU7i1HV49qJ46isEtAs","displayName":"user"}}
+```
+
+Everything in there is a Spring Security default, and every one of them is a decision:
+
+| field | default | what it means |
+|---|---|---|
+| `attestation` | `none` | do not ask the authenticator to identify its make and model |
+| `residentKey` | `required` | a discoverable credential, so the user need not type a username |
+| `userVerification` | `preferred` | check a PIN or biometric **if convenient** — see [05](05-defaults.md) |
+| `pubKeyCredParams` | EdDSA, ES256, RS256 | in that order of preference |
+| `timeout` | 300000 ms | five minutes to complete the ceremony |
+| `extensions` | `credProps` | ask the browser whether the credential ended up discoverable |
+| `excludeCredentials` | this user's existing credentials | so the same authenticator is not enrolled twice |
+
+`user.id` is a random 32-byte handle generated by
+`Webauthn4JRelyingPartyOperations.findUserEntityOrCreateAndSave` on first use. It is **not**
+the username, and it must not be: it is stored in the authenticator, it syncs to the user's
+other devices, and it is visible to anything that can talk to the authenticator.
+
+## Authentication
+
+```
+POST /webauthn/authenticate/options (CSRF token required; no session needed)
+ -> challenge, rpId, allowCredentials, userVerification, timeout
+
+navigator.credentials.get({ publicKey: options })
+ -> authenticatorData, clientDataJSON, an ECDSA signature, and a userHandle
+
+POST /login/webauthn { id, rawId, response: {...}, type: "public-key" }
+ -> { "authenticated": true, "redirectUrl": "/" } or a bare 401
+```
+
+```json
+{"allowCredentials":[],"challenge":"18HVET3lRrveXvRdKS1K5k2Ji_3nqm1Krx_l3ZOB0wU","extensions":{},
+ "rpId":"localhost","timeout":300000,"userVerification":"preferred"}
+```
+
+`allowCredentials` is empty because the caller is anonymous:
+`Webauthn4JRelyingPartyOperations.findCredentialRecords` returns an empty list when there is
+no authenticated user, and the browser falls back to offering whatever discoverable
+credentials it holds for that rpId. That empty array is the *usernameless* login most people
+mean when they say “passkey”, and it only works because `residentKey` defaulted to
+`required` during registration. The two settings are one decision made in two places.
+
+## What the signature actually covers
+
+```
+signature = ECDSA-SHA256( authenticatorData || SHA-256(clientDataJSON) )
+```
+
+`authenticatorData` is `rpIdHash(32) || flags(1) || signCount(4)`, with attested credential
+data appended during registration and omitted during assertion. `clientDataJSON` carries the
+type, the challenge and the **origin** — and it is the browser, not the page, that fills
+the origin in. That is the entire phishing defence: a credential minted for `bank.example` is
+never offered to `bank-example.evil`, and even if it were, the origin in client data would not
+match and the relying party would refuse. [07 — Failure modes](07-failure-modes.md)
+shows that refusal happening.
+
+## The bit that surprises people
+
+Nothing above involves a password, and nothing above involves a username either — but
+the registration ceremony required an authenticated session to even start. The credential is
+bound to a user who was already identified some other way. See
+[06 — The bootstrap problem](06-the-bootstrap-problem.md).
+
+[← 02 — The minimum configuration](02-minimum-configuration.md) · [index](README.md) · next: [04 — A software authenticator](04-virtual-authenticator.md)
diff --git a/docs/passkeys/04-virtual-authenticator.md b/docs/passkeys/04-virtual-authenticator.md
new file mode 100644
index 0000000..76fbb53
--- /dev/null
+++ b/docs/passkeys/04-virtual-authenticator.md
@@ -0,0 +1,96 @@
+[← 03 — The two ceremonies](03-the-two-ceremonies.md) · [index](README.md) · next: [05 — The defaults](05-defaults.md)
+
+# A software authenticator, so the ceremonies can be executed
+
+Every claim in these chapters comes from a run, which means something had to play the part of
+the security key. Chrome's DevTools virtual authenticator can do it interactively; it cannot
+be scripted into `run-all.sh`, and it cannot be checked into a repository.
+
+[`VirtualAuthenticator`](../../passkeys/src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java)
+is about two hundred lines and does the whole job. Spring Security and WebAuthn4J verify its
+output without knowing that no hardware was involved — which is itself worth noticing.
+
+## What it has to produce
+
+**Authenticator data**, a fixed binary layout:
+
+```
+rpIdHash 32 bytes SHA-256 of the rpId string, not of the origin
+flags 1 byte UP 0x01, UV 0x04, BE 0x08, BS 0x10, AT 0x40
+signCount 4 bytes big endian
+attestedCredData variable registration only: aaguid(16) || credIdLen(2) || credId || COSE key
+```
+
+**A COSE public key**, CBOR, for ES256:
+
+```java
+new Cbor().map(5)
+ .num(1).num(2) // kty: EC2
+ .num(3).num(-7) // alg: ES256
+ .num(-1).num(1) // crv: P-256
+ .num(-2).bytes(x) // 32 bytes, left-padded
+ .num(-3).bytes(y) // 32 bytes, left-padded
+ .toByteArray();
+```
+
+The left-padding matters. `BigInteger.toByteArray()` returns a two's-complement encoding: it
+prepends a zero byte when the top bit is set, and it drops leading zero bytes when they are
+not. Either way you get 31 or 33 bytes roughly half the time, and the relying party rejects
+the key without telling you why.
+
+**An attestation object**, also CBOR:
+
+```java
+new Cbor().map(3)
+ .text("fmt").text("none")
+ .text("attStmt").map(0)
+ .text("authData").bytes(authData)
+ .toByteArray();
+```
+
+178 bytes, in this module's case. [`Cbor`](../../passkeys/src/main/java/com/ankurm/passkeys/virtual/Cbor.java)
+is a forty-line encoder covering the four CBOR major types WebAuthn needs; pulling in a
+library for this would hide the structure rather than explain it.
+
+**A signature** over `authenticatorData || SHA-256(clientDataJSON)`, using
+`SHA256withECDSA`, which emits the DER encoding WebAuthn expects.
+
+## What it deliberately does not do
+
+It sets the UP and UV flags because it was asked to, not because anything happened. There is
+no user presence test, no biometric, no secure element, and the private key sits in the heap
+next to everything else.
+
+That is not a shortcoming, it is the point. A relying party cannot tell the difference between
+this and a real authenticator unless it verifies attestation — and by default Spring
+Security does not. See [05 — The defaults](05-defaults.md).
+
+## Driving it
+
+[`tools/PasskeyCeremony.java`](../../passkeys/tools/PasskeyCeremony.java) is a single-file
+source program (JEP 458, so it compiles its dependencies from the same directory on the fly).
+It uses `java.net.http.HttpClient` with a real cookie jar and drives the actual HTTP endpoints,
+CSRF tokens and all — not the operations bean directly — so filter ordering,
+session handling and response codes are exercised too.
+
+```bash
+java --class-path "target/classes:$(cat target/deps.txt)" tools/PasskeyCeremony.java register-and-login
+```
+
+| scenario | what it shows |
+|---|---|
+| `register-and-login` | both ceremonies end to end |
+| `clone-counter` | the signature counter, and what is done with it |
+| `no-uv` | a credential whose UV flag was never set |
+| `wrong-origin` / `wrong-origin-login` | a phishing attempt, in each ceremony |
+| `duplicate` | `excludeCredentials`, and a client that ignores it |
+| `bootstrap` | asking for registration options with nobody logged in |
+| `ott` | the one-time-token fallback |
+| `filters` | the live filter chain |
+
+The [contract tests](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java)
+use the same authenticator against `Webauthn4JRelyingPartyOperations` directly, with no HTTP
+and no Spring context, so a failure there is the framework's behaviour rather than a filter
+accident.
+
+[← 03 — The two ceremonies](03-the-two-ceremonies.md) · [index](README.md) · next: [05 — The defaults](05-defaults.md)
diff --git a/docs/passkeys/05-defaults.md b/docs/passkeys/05-defaults.md
new file mode 100644
index 0000000..19786ee
--- /dev/null
+++ b/docs/passkeys/05-defaults.md
@@ -0,0 +1,126 @@
+[← 04 — A software authenticator](04-virtual-authenticator.md) · [index](README.md) · next: [06 — The bootstrap problem](06-the-bootstrap-problem.md)
+
+# The defaults, and what they do not check
+
+Spring Security's passkey defaults are reasonable. They are also weaker than most people
+assume, in ways that do not show up until you go looking.
+
+## userVerification is `preferred`, which means optional
+
+`Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions` builds:
+
+```java
+AuthenticatorSelectionCriteria.builder()
+ .userVerification(UserVerificationRequirement.PREFERRED)
+ .residentKey(ResidentKeyRequirement.REQUIRED)
+ .build();
+```
+
+and both `registerCredential` and `authenticate` decide whether to enforce UV like this:
+
+```java
+boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
+ .equals(creationOptions.getAuthenticatorSelection().getUserVerification());
+```
+
+`PREFERRED` is not `REQUIRED`, so the flag is not checked. An authenticator that answers with
+UV clear registers successfully and logs in successfully. From
+[`docs/output/pk-user-verification.txt`](../output/pk-user-verification.txt):
+
+```
+$ POST /webauthn/register
+HTTP 200
+{"success":true}
+
+$ GET /diag/credentials (note uvInitialized)
+{"credentials":[{"label":"no-uv", ... "signatureCount":0,"uvInitialized":false, ...}]}
+
+$ POST /login/webauthn
+HTTP 200
+{"authenticated":true,"redirectUrl":"/"}
+```
+
+`uvInitialized: false` is recorded faithfully — and then nothing consults it. What you
+have is a single-factor credential: possession of the authenticator, with no proof that the
+person holding it is the enrolled user. For most consumer sites that is an acceptable trade
+(it is roughly what a password gives you, minus the phishing). For anything where the passkey
+*is* the second factor, it is not, and the fix is one setting on both ceremonies:
+
+```java
+operations.setCustomizeCreationOptions((options) -> options.authenticatorSelection(
+ AuthenticatorSelectionCriteria.builder()
+ .userVerification(UserVerificationRequirement.REQUIRED)
+ .residentKey(ResidentKeyRequirement.REQUIRED)
+ .build()));
+operations.setCustomizeRequestOptions((options) ->
+ options.userVerification(UserVerificationRequirement.REQUIRED));
+```
+
+Setting only the first leaves logins unverified forever, which is the worst of the three
+outcomes because registration looks correct. The `uvrequired` profile in this module sets
+both; with it, the same authenticator is refused:
+
+```
+com.webauthn4j.verifier.exception.UserNotVerifiedException:
+ Verifier is configured to check user verified, but UV flag in authenticatorData is not set.
+```
+
+`AuthenticatorSelectionCriteria.builder()` has no copy constructor, so overriding
+`userVerification` means restating `residentKey` too. Forget it and you silently drop back to
+a non-discoverable credential, and usernameless login stops working.
+
+## Asking for attestation is not verifying attestation
+
+The default is `AttestationConveyancePreference.NONE`. Switch it to `DIRECT` and the options
+change:
+
+```json
+{"attestation":"direct","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"}, ...}
+```
+
+Then register with an authenticator that answers `fmt: "none"` and an all-zero AAGUID. From
+[`docs/output/pk-attestation.txt`](../output/pk-attestation.txt):
+
+```
+$ POST /webauthn/register
+HTTP 200
+{"success":true}
+```
+
+The reason is the manager. `Webauthn4JRelyingPartyOperations` initialises with:
+
+```java
+private WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
+```
+
+and that factory installs `NullFIDOU2FAttestationStatementVerifier`,
+`NullPackedAttestationStatementVerifier`, `NullTPMAttestationStatementVerifier`,
+`NullAndroidKeyAttestationStatementVerifier`, `NullAndroidSafetyNetAttestationStatementVerifier`,
+`NullAppleAnonymousAttestationStatementVerifier`, a `NullCertPathTrustworthinessVerifier` and a
+`NullSelfAttestationTrustworthinessVerifier`. Everything that could check an attestation
+statement is a null object.
+
+WebAuthn4J 0.31.9 does not ship a strict counterpart. `WebAuthnManager` has exactly two static
+factories, `createNonStrictWebAuthnManager()` and its `ObjectConverter` overload; a strict
+manager has to be assembled from real verifiers and a `TrustAnchorRepository` by hand, and
+then `setWebAuthnManager` on the operations bean. That is a real project, and it is only worth
+starting if you have an enterprise reason to care which model of key your users hold. For a
+consumer product, attestation is usually the wrong thing to spend effort on — but you
+should know that the `direct` in your options object is currently decorative.
+
+## What *is* checked, by default, and correctly
+
+Not everything is a caveat. Out of the box, all of these hold:
+
+- **Origin.** A mismatch is rejected in both ceremonies. This is the phishing defence and it works.
+- **Challenge.** Persisted in the `HttpSession` between the options call and the ceremony call, so a replayed or fabricated challenge fails.
+- **rpId hash.** Compared against the configured relying party.
+- **Signature.** Verified against the stored COSE key, with the algorithm the key declares.
+- **User presence.** `userPresenceRequired` is hard-coded `true` at registration, per the specification.
+- **Duplicate credential ids.** `registerCredential` rejects an id that already exists.
+
+The unchecked list is short: user verification unless you ask for it, attestation unless you
+build for it, and the signature counter — which gets its own chapter,
+[10 — The signature counter](10-signature-counter.md).
+
+[← 04 — A software authenticator](04-virtual-authenticator.md) · [index](README.md) · next: [06 — The bootstrap problem](06-the-bootstrap-problem.md)
diff --git a/docs/passkeys/06-the-bootstrap-problem.md b/docs/passkeys/06-the-bootstrap-problem.md
new file mode 100644
index 0000000..0a6362e
--- /dev/null
+++ b/docs/passkeys/06-the-bootstrap-problem.md
@@ -0,0 +1,106 @@
+[← 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)
diff --git a/docs/passkeys/07-failure-modes.md b/docs/passkeys/07-failure-modes.md
new file mode 100644
index 0000000..db357d5
--- /dev/null
+++ b/docs/passkeys/07-failure-modes.md
@@ -0,0 +1,92 @@
+[← 06 — The bootstrap problem](06-the-bootstrap-problem.md) · [index](README.md) · next: [08 — The one-time-token fallback](08-one-time-token-fallback.md)
+
+# Failure modes, and what each one looks like
+
+Passkey failures are quiet. The authentication endpoint returns a bare 401 with no body, the
+registration endpoint returns a 500 with a generic error page, and the reason is only ever in
+the server log at DEBUG. This chapter is a lookup table.
+
+Run with the `trace` profile to see any of it:
+
+```bash
+./scripts/run.sh trace
+```
+
+## Every registration failure is an HTTP 500
+
+This is worth stating on its own, because it is the first thing that confuses people.
+`WebAuthnRegistrationFilter` has no error handling: whatever WebAuthn4J throws propagates out
+of the filter and Boot's error page turns it into a 500. Three different mistakes, three
+identical responses ([`pk-origin.txt`](../output/pk-origin.txt),
+[`pk-user-verification.txt`](../output/pk-user-verification.txt),
+[`pk-duplicate.txt`](../output/pk-duplicate.txt)):
+
+```
+$ POST /webauthn/register
+HTTP 500
+{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
+```
+
+Authentication is different. `WebAuthnAuthenticationProvider` catches everything:
+
+```java
+catch (RuntimeException ex) {
+ throw new BadCredentialsException(ex.getMessage(), ex);
+}
+```
+
+so the client gets a clean `401` with an empty body, and the cause is discarded before it
+reaches any response the browser can see. Both behaviours mean the same thing: **read the
+server log, not the HTTP response**.
+
+## The table
+
+| symptom | cause | where to look |
+|---|---|---|
+| `HTTP 400`, empty body, on `/webauthn/register/options` | nobody is logged in, or the session expired | `IllegalArgumentException: Authentication must be authenticated` |
+| `HTTP 403` on any `/webauthn/**` POST | missing CSRF token — all five endpoints are state-changing POSTs | `CsrfFilter` at position 5 |
+| `HTTP 500` on `/webauthn/register` | origin mismatch | `BadOriginException: The collectedClientData origin '...' doesn't match expected: ...` |
+| `HTTP 500` on `/webauthn/register` | UV required, authenticator did not verify | `UserNotVerifiedException: ... UV flag in authenticatorData is not set` |
+| `HTTP 500` on `/webauthn/register` | the credential id is already registered | `IllegalArgumentException: Credential with id ... already exists` |
+| `HTTP 401`, empty body, on `/login/webauthn` | origin mismatch, bad signature, unknown credential id, or a null stored attestation object | `BadCredentialsException` wrapping the real cause |
+| `navigator.credentials.create` never prompts | not a secure context — plain HTTP on anything but `localhost` | browser console, not the server |
+| credentials vanish on restart | `MapUserCredentialRepository`, the default | [09 — Persistence](09-persistence.md) |
+| `NullPointerException` during registration | an authenticator transport Spring does not know, e.g. `cable` | [spring-security#19366](https://github.com/spring-projects/spring-security/issues/19366) |
+| WebAuthn breaks under Spring Session with Redis | creation options serialisation | [spring-security#16328](https://github.com/spring-projects/spring-security/issues/16328) |
+
+## The origin mismatch, in full
+
+This is the one that matters, because it is the phishing defence working. From
+[`docs/output/pk-origin.txt`](../output/pk-origin.txt), with the client sending
+`http://evil.localhost:8080` and the relying party configured for `http://localhost:8080`:
+
+```
+com.webauthn4j.verifier.exception.BadOriginException: The collectedClientData origin
+ 'http://evil.localhost:8080' doesn't match expected: http://localhost:8080
+ at com.webauthn4j.verifier.OriginVerifierImpl.verify(OriginVerifierImpl.java:72)
+ at com.webauthn4j.verifier.RegistrationDataVerifier.verify(RegistrationDataVerifier.java:171)
+```
+
+and the same mistake one ceremony later:
+
+```
+$ POST /login/webauthn (origin http://evil.localhost:8080)
+HTTP 401
+(empty body)
+```
+
+In a real browser this failure is not reachable: the browser writes the origin itself and the
+credential is scoped to an rpId, so a phishing page never gets an assertion to send. The
+software authenticator in this module can lie about its origin precisely so the server-side
+half of the check can be watched.
+
+## Debugging checklist
+
+1. Is the rpId a domain, and does it match or registrably-suffix the origin's host?
+2. Is the allowed origin the exact string, with scheme and port?
+3. Is there a `WebAuthnRelyingPartyOperations` bean quietly overriding the DSL? ([02](02-minimum-configuration.md))
+4. Is the request a POST with a CSRF token?
+5. Is the session the same one that received the challenge? The default `PublicKeyCredentialCreationOptionsRepository` is `HttpSession`-backed, so a load balancer without sticky sessions breaks registration and nothing else.
+6. Turn on `logging.level.com.webauthn4j: DEBUG` and read the actual exception.
+
+[← 06 — The bootstrap problem](06-the-bootstrap-problem.md) · [index](README.md) · next: [08 — The one-time-token fallback](08-one-time-token-fallback.md)
diff --git a/docs/passkeys/08-one-time-token-fallback.md b/docs/passkeys/08-one-time-token-fallback.md
new file mode 100644
index 0000000..0445835
--- /dev/null
+++ b/docs/passkeys/08-one-time-token-fallback.md
@@ -0,0 +1,99 @@
+[← 07 — Failure modes](07-failure-modes.md) · [index](README.md) · next: [09 — Persistence](09-persistence.md)
+
+# The one-time-token fallback
+
+`oneTimeTokenLogin` is Spring Security's magic link. It exists independently of passkeys, but
+it is the natural partner: it solves the bootstrap problem in [06](06-the-bootstrap-problem.md)
+and it is the recovery path when a device is lost.
+
+```java
+.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(handler))
+```
+
+## It will not start without a delivery handler
+
+There is no default `OneTimeTokenGenerationSuccessHandler`, and there cannot be a sensible one
+— Spring has no idea whether you send email, SMS or a push. Omit the bean and the
+context fails to start. That is the correct decision and it is also the first error everybody
+hits.
+
+The handler in this module prints the link and writes the token to a file so the demo scripts
+can read it:
+
+```java
+System.out.printf("[one-time-token] username=%s expires=%s%n[one-time-token] %s%n",
+ oneTimeToken.getUsername(), oneTimeToken.getExpiresAt(), link);
+Files.writeString(TOKEN_FILE, oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
+this.redirect.handle(request, response, oneTimeToken);
+```
+
+The `redirect.handle(..)` at the end is not optional: the handler owns the HTTP response, so
+if it does not write one the browser gets a blank page.
+`RedirectOneTimeTokenGenerationSuccessHandler("/login/ott")` sends the user to the built-in
+submit page.
+
+## The flow, and the endpoints
+
+| method | path | what it does |
+|---|---|---|
+| `POST` | `/ott/generate` | takes `username`, generates a token, calls your handler |
+| `GET` | `/login/ott` | the built-in submit page, prefilled if `?token=` is present |
+| `POST` | `/login/ott` | redeems the token and creates the session |
+
+From [`docs/output/pk-ott.txt`](../output/pk-ott.txt):
+
+```
+POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
+token delivered out of band (the handler wrote it to a file): 3203202f-7e17-4f8b-a613-0d64831c3530
+POST /login/ott -> HTTP 302, Location: http://localhost:8080/
+
+$ GET /me
+{"name":"user","authenticationType":"OneTimeTokenAuthentication",
+ "authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=...]","ROLE_USER"]}
+```
+
+Single use is enforced — `OneTimeTokenService.consume` removes it:
+
+```
+=== The same token, a second time ===
+POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error
+```
+
+## It does not tell an attacker whether the account exists
+
+`InMemoryOneTimeTokenService.generate` takes a `GenerateOneTimeTokenRequest` carrying a
+username and nothing else. There is no `UserDetailsService` involved, so it cannot check
+whether the user exists, and it does not try. From
+[`docs/output/pk-bootstrap.txt`](../output/pk-bootstrap.txt), for a username that is not in
+the `UserDetailsService`:
+
+```
+POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
+a token was still generated and delivered: f9f58b17-f2ee-498c-86fe-da3570f9108e
+POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error (the failure lands here instead)
+```
+
+Byte-identical to a real username, so there is no account-enumeration oracle at the generate
+endpoint. The failure happens at redemption, in `OneTimeTokenAuthenticationProvider`, where
+nobody is listening.
+
+This falls out of the design rather than being aimed at, and it is currently an open question
+in the project — [spring-security#16483](https://github.com/spring-projects/spring-security/issues/16483)
+argues that a token should not be created for a user who does not exist. If that changes, check
+that the response for an unknown username still matches the response for a known one, because
+it is very easy to fix the storage waste and open an enumeration hole in the same commit.
+
+## Defaults worth changing
+
+| setting | default | why you might move it |
+|---|---|---|
+| token TTL | 5 minutes (`GenerateOneTimeTokenRequest`) | shorter for recovery, since the mail arrives in seconds |
+| `OneTimeTokenService` | `InMemoryOneTimeTokenService` | `JdbcOneTimeTokenService` for more than one instance; the DDL is `org/springframework/security/core/ott/jdbc/one-time-tokens-schema.sql` |
+| token value | a UUID | `GenerateOneTimeTokenRequestResolver` if you need a short numeric code for SMS |
+| rate limiting | none | there is none. `/ott/generate` will happily mail somebody a hundred links |
+
+That last row is the one to act on. A magic-link endpoint with no rate limit is a mail bomb
+and a nuisance-denial-of-service against your own sending reputation. Spring Security does not
+ship a limiter; put one in front.
+
+[← 07 — Failure modes](07-failure-modes.md) · [index](README.md) · next: [09 — Persistence](09-persistence.md)
diff --git a/docs/passkeys/09-persistence.md b/docs/passkeys/09-persistence.md
new file mode 100644
index 0000000..a5c0982
--- /dev/null
+++ b/docs/passkeys/09-persistence.md
@@ -0,0 +1,93 @@
+[← 08 — The one-time-token fallback](08-one-time-token-fallback.md) · [index](README.md) · next: [10 — The signature counter](10-signature-counter.md)
+
+# Persistence
+
+Two repositories, and the default for both is a `HashMap`.
+
+| interface | default | JDBC implementation |
+|---|---|---|
+| `PublicKeyCredentialUserEntityRepository` | `MapPublicKeyCredentialUserEntityRepository` | `JdbcPublicKeyCredentialUserEntityRepository` |
+| `UserCredentialRepository` | `MapUserCredentialRepository` | `JdbcUserCredentialRepository` |
+
+`WebAuthnConfigurer.configure` constructs the map-backed ones if no beans exist, without
+logging anything. In a passwordless application that is a trap with teeth: restart the process
+and every user's only credential is gone, and by design they have no password to fall back on.
+
+## Switching to JDBC
+
+```java
+@Bean
+PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
+ return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
+}
+
+@Bean
+UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
+ return new JdbcUserCredentialRepository(jdbc);
+}
+```
+
+Nothing creates the tables. The Javadoc points at
+`classpath:org/springframework/security/user-credentials-schema.sql`, and searching
+`spring-security-webauthn-7.1.1.jar` for it finds nothing — the two DDL files stayed in
+`spring-security-web` when the classes moved out in 7.0 ([01](01-versions.md)). They are still
+on the classpath transitively, so this works:
+
+```yaml
+spring:
+ sql:
+ init:
+ mode: always
+ schema-locations:
+ - classpath:org/springframework/security/user-entities-schema.sql
+ - classpath:org/springframework/security/user-credentials-schema.sql
+```
+
+There is a Postgres variant, `user-credentials-schema-postgres.sql`, which differs only in
+`bytea` versus `blob`. There is no Postgres variant of the user entities schema, because it
+does not need one.
+
+Run it with `./scripts/run.sh jdbc`; the ceremony transcript is
+[`docs/output/pk-jdbc.txt`](../output/pk-jdbc.txt) and is identical to the in-memory one, which
+is the point.
+
+## What a credential record holds
+
+```
+credential_id the raw id, base64url
+user_entity_user_id the random user handle, not the username
+public_key the COSE key, as CBOR bytes
+signature_count updated on every assertion - see chapter 10
+uv_initialized whether UV was set at registration
+backup_eligible / backup_state whether this is a syncable passkey, and whether it is synced
+authenticator_transports internal, hybrid, usb, nfc, ble
+attestation_object the full attestation object from registration
+attestation_client_data_json the client data from registration
+created / last_used timestamps
+label whatever the user typed
+```
+
+`attestation_object` is nullable in the DDL and mandatory in practice.
+`Webauthn4JRelyingPartyOperations.authenticate` re-parses it on every login to recover the COSE
+key:
+
+```java
+Bytes attestationObject = credentialRecord.getAttestationObject();
+Assert.notNull(attestationObject, "attestationObject cannot be null");
+AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), ...);
+```
+
+So a custom `UserCredentialRepository` that stores the public key but drops the attestation
+object — a reasonable-looking optimisation, since the public key is right there in its
+own column — produces credentials that register fine and can never log in. The symptom is
+a bare 401. That re-parse is also the mechanism behind
+[chapter 10](10-signature-counter.md).
+
+## Things to decide before you ship
+
+- **Deleting a credential.** `DELETE /webauthn/register/{id}` exists and is guarded by `CredentialRecordOwnerAuthorizationManager`, so users cannot delete each other's passkeys. Make sure your UI exposes it, and make sure a user cannot delete their last one.
+- **Labels.** The label is user-supplied and displayed back; treat it as untrusted, and cap its length. The column is `varchar(1000)`.
+- **Session storage.** The challenge lives in the `HttpSession` between the options call and the ceremony call. Behind a load balancer without sticky sessions, registration fails intermittently and nothing else does. Spring Session with Redis has its own open issue — [spring-security#16328](https://github.com/spring-projects/spring-security/issues/16328).
+- **Backups.** In a passwordless application the credential table *is* the account. Losing it is not a data-loss incident, it is a lockout incident for every user at once.
+
+[← 08 — The one-time-token fallback](08-one-time-token-fallback.md) · [index](README.md) · next: [10 — The signature counter](10-signature-counter.md)
diff --git a/docs/passkeys/10-signature-counter.md b/docs/passkeys/10-signature-counter.md
new file mode 100644
index 0000000..a0a42d9
--- /dev/null
+++ b/docs/passkeys/10-signature-counter.md
@@ -0,0 +1,138 @@
+[← 09 — Persistence](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md)
+
+# The signature counter is stored on every login and compared against on none
+
+WebAuthn's signature counter exists for one purpose: detecting a cloned authenticator. A
+hardware key increments a monotonic counter each time it signs, so if the relying party ever
+sees a counter it has already seen, two copies of the private key are in circulation.
+
+Spring Security persists that counter faithfully. It never reads it back for verification.
+
+## Watching it happen
+
+[`scripts/counter.sh`](../../passkeys/scripts/counter.sh) registers one credential, then
+authenticates three times with counters 1, 2 and 3, checking what the relying party stored
+after each. Then it presents a counter of 1 again. From
+[`docs/output/pk-counter.txt`](../output/pk-counter.txt):
+
+```
+--- assertion 1, authenticator signCount = 1
+$ POST /login/webauthn
+HTTP 200
+{"authenticated":true,"redirectUrl":"/"}
+stored signatureCount now: 1
+
+--- assertion 2, authenticator signCount = 2
+stored signatureCount now: 2
+
+--- assertion 3, authenticator signCount = 3
+stored signatureCount now: 3
+
+=== Replaying a stale counter. A cloned key would look exactly like this ===
+--- assertion 4, authenticator signCount = 1 (lower than the stored 3)
+$ POST /login/webauthn
+HTTP 200
+{"authenticated":true,"redirectUrl":"/"}
+
+replayed a counter of 1 after the relying party had stored 3: HTTP 200
+```
+
+The stored value walks 1, 2, 3 exactly as you would hope. Then a counter of 1 is accepted.
+
+## Why
+
+WebAuthn4J *does* implement the check. `AuthenticationDataVerifier` follows the specification
+line by line:
+
+```java
+long presentedSignCount = authenticatorData.getSignCount();
+long storedSignCount = authenticator.getCounter();
+if (presentedSignCount > 0 || storedSignCount > 0) {
+ if (presentedSignCount > storedSignCount) { ... }
+ else { maliciousCounterValueHandler.maliciousCounterValueDetected(authenticationObject); }
+}
+```
+
+and the default handler throws `MaliciousCounterValueException` with the message
+“Malicious counter value is detected. Cloned authenticators exist in parallel.”
+
+The gap is in what `authenticator.getCounter()` returns. Spring builds the WebAuthn4J
+credential record like this:
+
+```java
+AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), AttestationObject.class);
+com.webauthn4j.credential.CredentialRecord wa4jCredentialRecord =
+ new CredentialRecordImpl(wa4jAttestationObject, null, null, transports);
+```
+
+— `Webauthn4JRelyingPartyOperations.authenticate`, Spring Security 7.1.1
+
+and that constructor derives the counter from the attestation object:
+
+```java
+public CoreCredentialRecordImpl(@NotNull AttestationObject attestationObject) {
+ super(attestationObject.getAuthenticatorData().getAttestedCredentialData(),
+ attestationObject.getAttestationStatement(),
+ attestationObject.getAuthenticatorData().getSignCount(), // <-- the counter
+ attestationObject.getAuthenticatorData().getExtensions());
+```
+
+— `CoreCredentialRecordImpl`, WebAuthn4J 0.31.9
+
+The attestation object is the one captured at **registration**. Its counter is frozen at
+whatever the authenticator reported then — almost always 0. So the comparison is always
+“is the presented counter greater than 0?”, the persisted `signature_count` column
+is never consulted, and the only assertion that would ever trip the check is one presenting a
+counter of exactly 0 against a registration counter of 0. Which is precisely what every
+synced passkey does, on every login, forever — and those are not rejected either, because
+of the `presentedSignCount > 0 || storedSignCount > 0` guard.
+
+The contract test
+[`signatureCounterIsStoredButNotVerified`](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java)
+pins this at the operations level, with no HTTP involved, and will start failing the day it
+changes.
+
+## How much does this matter?
+
+Less than the paragraphs above suggest, and it is worth being precise about why.
+
+**The specification treats a mismatch as a signal, not a verdict.** WebAuthn Level 3 does make
+the comparison a step in the assertion verification procedure. What it does *not* do is
+prescribe the response: a counter that fails to increase is “a signal, but not proof,
+that the authenticator may be cloned”, since it might equally be a malfunctioning
+authenticator or assertions processed out of order, and relying parties are told to
+“evaluate their own operational characteristics and incorporate this information into
+their risk scoring”. Spring Security sits at the permissive end of that range rather
+than outside it.
+
+**Modern passkeys have no counter.** A credential synced through iCloud Keychain, Google
+Password Manager or 1Password exists on several devices by design; a monotonic counter is
+meaningless across them, so those authenticators report 0 permanently. For a consumer
+application where most credentials are syncable, counter checking would detect nothing and
+would risk false positives.
+
+**It is still worth knowing.** If you are enrolling FIDO2 hardware keys in an enterprise
+setting, the counter is real, it increments, and the check is a genuine clone detector —
+and you do not have it. The `signature_count` column filling up correctly makes it look like
+you do, which is the actual hazard here: not a hole, but a control you might believe you have.
+
+## If you need it
+
+Nothing in the API blocks you. Wrap the operations bean:
+
+```java
+@Bean
+WebAuthnRelyingPartyOperations relyingPartyOperations(UserCredentialRepository credentials, ...) {
+ Webauthn4JRelyingPartyOperations delegate = new Webauthn4JRelyingPartyOperations(...);
+ return new CounterCheckingRelyingPartyOperations(delegate, credentials);
+}
+```
+
+where the wrapper reads `credentials.findByCredentialId(id).getSignatureCount()` before
+delegating, remembers it, and after `authenticate` returns compares it against the value the
+delegate has just written — rejecting the login if it did not increase and either value
+was non-zero. That reproduces the specification's rule using the column Spring already
+maintains. Alert rather than block if syncable passkeys are in the mix; a permanent 0 must not
+lock anybody out.
+
+[← 09 — Persistence](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md)
diff --git a/docs/passkeys/11-should-you.md b/docs/passkeys/11-should-you.md
new file mode 100644
index 0000000..f0f61d6
--- /dev/null
+++ b/docs/passkeys/11-should-you.md
@@ -0,0 +1,49 @@
+[← 10 — The signature counter](10-signature-counter.md) · [index](README.md)
+
+# Should you build this
+
+## Yes, if
+
+- **You have consumer users and a password reset problem.** Passkeys remove credential stuffing and phishing in one move, and both are probably in your incident history.
+- **You already federate.** If most users arrive through Google or Apple, they already have a passkey-capable account recovery story, and you are adding a first-party option rather than inventing one.
+- **You can afford a second credential.** Passkeys are excellent when a user has two. They are a support burden when a user has one and drops their phone in a canal.
+- **Your users are on current devices.** Platform authenticator support is effectively universal on hardware from the last few years, and effectively absent below it.
+
+## No, or not yet, if
+
+- **Your recovery path is an email magic link and nothing else.** Then your security level is your users' email accounts, and you have added complexity without adding strength. Fix recovery first; the passkeys will still be there.
+- **You cannot run HTTPS everywhere, including in development.** `localhost` is the only exception browsers make. A staging environment on plain HTTP simply cannot run the ceremony.
+- **You are behind a load balancer without sticky sessions and cannot add shared session storage.** The challenge lives in the `HttpSession`. Registration will fail intermittently and the logs will not obviously say why.
+- **You need attestation.** Verifying which model of authenticator a user holds is a real project on top of what Spring Security gives you ([05](05-defaults.md)), and requires a trust-anchor source such as the FIDO Metadata Service.
+- **You are counting on clone detection.** You do not have it ([10](10-signature-counter.md)).
+
+## What it actually costs
+
+The Spring Security part is genuinely small: one dependency, one DSL block, one
+`UserDetailsService`. Working passkey login in an afternoon is a fair estimate, and the
+built-in registration and login pages mean you can demonstrate it before writing any
+JavaScript.
+
+The rest is not small:
+
+| work | why it is not optional |
+|---|---|
+| a delivery channel for one-time tokens | the bootstrap problem ([06](06-the-bootstrap-problem.md)) |
+| a credential management UI | list, label, delete, and refuse to delete the last one |
+| recovery policy, rate limits, notifications | this is where your real security level is set |
+| a real front end | the built-in pages are a reference, not a product |
+| persistence and backups | the credential table is the account ([09](09-persistence.md)) |
+| a fallback for unsupported clients | which means keeping passwords or federation alive during the migration |
+
+The honest framing is that Spring Security 7.1 has removed the protocol from your list of
+problems, and left you with all the product ones. That is a good trade — it is just not
+the same as being finished.
+
+## Where to go next
+
+- [`docs/01`–`18`](../) — JWT authentication and OAuth2 resource servers, the other two projects in this repository
+- [`docs/authorization-server/`](../authorization-server/README.md) — running your own OAuth2 / OIDC provider, which is the other way to solve the bootstrap problem
+- [WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) — the specification, and readable
+- [passkeys.dev](https://passkeys.dev) — device and browser support, and the UX conventions users now expect
+
+[← 10 — The signature counter](10-signature-counter.md) · [index](README.md)
diff --git a/docs/passkeys/README.md b/docs/passkeys/README.md
new file mode 100644
index 0000000..acee1f7
--- /dev/null
+++ b/docs/passkeys/README.md
@@ -0,0 +1,70 @@
+# Passkeys and WebAuthn with Spring Security 7.1
+
+Companion documentation for
+[Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/)
+on ankurm.com, and for the code in [`passkeys/`](../../passkeys).
+
+The other three projects in this repository move bearer tokens around. This one gets rid of
+the password — and then spends most of its length on the parts that are not the
+ceremony, because the ceremony is the easy half.
+
+Everything here was run. There is no browser and no hardware key anywhere in this module: a
+[software authenticator](04-virtual-authenticator.md) produces genuine CBOR attestation objects
+and genuine ES256 assertion signatures, and Spring Security verifies them without noticing.
+
+| | |
+|---|---|
+| JDK | Temurin **25.0.4.1+1** (current LTS) |
+| Spring Boot | **4.1.1** |
+| Spring Framework | **7.0.9** |
+| Spring Security | **7.1.1** (GA 20 August 2026) |
+| `spring-security-webauthn` | **7.1.1** — a separate artifact since 7.0 |
+| WebAuthn4J | **0.31.9.RELEASE** |
+| Jackson | **3.1.5** (`tools.jackson`) |
+| Tomcat | **11.0.24** |
+| Maven | 3.9.11 |
+
+Every file in [`docs/output/pk-*.txt`](../output) is real program output, regenerated by
+[`passkeys/scripts/run-all.sh`](../../passkeys/scripts/run-all.sh).
+
+## Chapters
+
+| # | chapter | what it settles |
+|---|---|---|
+| 01 | [Versions, artifacts and the 7.0 split](01-versions.md) | the dependency `spring-boot-starter-security` does not give you |
+| 02 | [The minimum configuration](02-minimum-configuration.md) | six endpoints from one DSL block, and the bean that silently disables it |
+| 03 | [The two ceremonies](03-the-two-ceremonies.md) | what is on the wire, and what every default in the options object means |
+| 04 | [A software authenticator](04-virtual-authenticator.md) | how to execute a passkey ceremony in CI, with no browser |
+| 05 | [The defaults](05-defaults.md) | user verification is optional, and asking for attestation is not checking it |
+| 06 | [The bootstrap problem](06-the-bootstrap-problem.md) | a passkey cannot be a user's first credential |
+| 07 | [Failure modes](07-failure-modes.md) | why every registration failure is a 500 and every login failure is a bare 401 |
+| 08 | [The one-time-token fallback](08-one-time-token-fallback.md) | the way in, the way back, and the rate limit that does not exist |
+| 09 | [Persistence](09-persistence.md) | the in-memory default, the missing DDL, and the column you must not drop |
+| 10 | [The signature counter](10-signature-counter.md) | stored on every login, compared against on none |
+| 11 | [Should you build this](11-should-you.md) | the honest answer, and what the afternoon actually costs |
+
+## Captured output
+
+| file | produced by | shows |
+|---|---|---|
+| [`pk-ceremony.txt`](../output/pk-ceremony.txt) | `scripts/ceremony.sh` | registration and authentication, end to end |
+| [`pk-counter.txt`](../output/pk-counter.txt) | `scripts/counter.sh` | a stale signature counter being accepted |
+| [`pk-user-verification.txt`](../output/pk-user-verification.txt) | `scripts/user-verification.sh` | `preferred` versus `required`, same authenticator |
+| [`pk-origin.txt`](../output/pk-origin.txt) | `scripts/origin.sh` | the phishing defence, in both ceremonies |
+| [`pk-attestation.txt`](../output/pk-attestation.txt) | `scripts/attestation.sh` | `direct` requested, `none` accepted |
+| [`pk-duplicate.txt`](../output/pk-duplicate.txt) | `scripts/duplicate.sh` | `excludeCredentials`, and a client that ignores it |
+| [`pk-bootstrap.txt`](../output/pk-bootstrap.txt) | `scripts/bootstrap.sh` | registration options with nobody logged in |
+| [`pk-step-up.txt`](../output/pk-step-up.txt) | `scripts/step-up.sh` | `FACTOR_WEBAUTHN` versus `FACTOR_OTT` on one endpoint |
+| [`pk-ott.txt`](../output/pk-ott.txt) | `scripts/ott-fallback.sh` | generate, redeem, and redeem again |
+| [`pk-jdbc.txt`](../output/pk-jdbc.txt) | `scripts/jdbc.sh` | the same ceremony against H2 |
+| [`pk-filters.txt`](../output/pk-filters.txt) | `scripts/filters.sh` | the live filter chain, all 25 of it |
+| [`pk-test-run.txt`](../output/pk-test-run.txt) | `scripts/test-run.sh` | seven contract tests |
+
+## Quickstart
+
+```bash
+cd spring-auth-demo/passkeys
+./scripts/run.sh # http://localhost:8080/login, user/password
+./scripts/ceremony.sh # both ceremonies, no browser
+./scripts/run-all.sh # regenerate every docs/output/pk-*.txt
+```
diff --git a/passkeys/pom.xml b/passkeys/pom.xml
new file mode 100644
index 0000000..83f4821
--- /dev/null
+++ b/passkeys/pom.xml
@@ -0,0 +1,84 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ passkeys-demo
+ 1.0.0
+ passkeys-demo
+ Passkeys and WebAuthn with Spring Security 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com
+
+
+
+ 25
+ UTF-8
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ org.springframework.security
+ spring-security-webauthn
+
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ com.h2database
+ h2
+ runtime
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/passkeys/scripts/attestation.sh b/passkeys/scripts/attestation.sh
new file mode 100755
index 0000000..374ae92
--- /dev/null
+++ b/passkeys/scripts/attestation.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Ask for DIRECT attestation. Register with fmt "none" and an all-zero AAGUID anyway.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app attestationdirect > /dev/null
+{
+ header "attestation: DIRECT requested, attestation: none accepted"
+ ceremony register-and-login
+} 2>&1 | tee "$OUTPUT_DIR/pk-attestation.txt"
+stop_app
diff --git a/passkeys/scripts/bootstrap.sh b/passkeys/scripts/bootstrap.sh
new file mode 100755
index 0000000..018cd43
--- /dev/null
+++ b/passkeys/scripts/bootstrap.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# The chicken-and-egg problem: a passkey cannot be a user's first credential.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "Bootstrapping - registering a passkey requires an existing authenticated session"
+ ceremony bootstrap
+} 2>&1 | tee "$OUTPUT_DIR/pk-bootstrap.txt"
+stop_app
diff --git a/passkeys/scripts/ceremony.sh b/passkeys/scripts/ceremony.sh
new file mode 100755
index 0000000..f8bc480
--- /dev/null
+++ b/passkeys/scripts/ceremony.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# The whole thing, end to end: password login, passkey registration, logout, passkey login.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "Registration and authentication ceremonies, driven without a browser"
+ echo "Spring Security 7.1.1, Spring Boot 4.1.1, rpId localhost, default settings."
+ echo "The authenticator is src/main/java/com/ankurm/passkeys/virtual/VirtualAuthenticator.java."
+ ceremony register-and-login
+} 2>&1 | tee "$OUTPUT_DIR/pk-ceremony.txt"
+stop_app
diff --git a/passkeys/scripts/counter.sh b/passkeys/scripts/counter.sh
new file mode 100755
index 0000000..d0f2d58
--- /dev/null
+++ b/passkeys/scripts/counter.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Three assertions with an increasing signature counter, then a replay of a stale one.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "Signature counter: stored on every assertion, compared against on none"
+ ceremony clone-counter
+} 2>&1 | tee "$OUTPUT_DIR/pk-counter.txt"
+stop_app
diff --git a/passkeys/scripts/duplicate.sh b/passkeys/scripts/duplicate.sh
new file mode 100755
index 0000000..8d7e538
--- /dev/null
+++ b/passkeys/scripts/duplicate.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# excludeCredentials, and what happens when the client ignores it.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "Registering the same credential id twice"
+ ceremony duplicate
+} 2>&1 | tee "$OUTPUT_DIR/pk-duplicate.txt"
+stop_app
diff --git a/passkeys/scripts/filters.sh b/passkeys/scripts/filters.sh
new file mode 100755
index 0000000..1283c05
--- /dev/null
+++ b/passkeys/scripts/filters.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Where the four WebAuthn filters and the two one-time-token filters sit in the chain.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "The security filter chain with webAuthn() and oneTimeTokenLogin() configured"
+ ceremony filters
+} 2>&1 | tee "$OUTPUT_DIR/pk-filters.txt"
+stop_app
diff --git a/passkeys/scripts/jdbc.sh b/passkeys/scripts/jdbc.sh
new file mode 100755
index 0000000..de8fdda
--- /dev/null
+++ b/passkeys/scripts/jdbc.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Credentials in a database, using the DDL that ships inside spring-security-web.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app jdbc > /dev/null
+{
+ header "JDBC persistence - H2, with Spring Security's own schema"
+ echo "schema-locations point at classpath:org/springframework/security/user-entities-schema.sql"
+ echo "and user-credentials-schema.sql, which live in spring-security-web, not in"
+ echo "spring-security-webauthn. Nothing creates these tables for you."
+ echo
+ ceremony register-and-login
+} 2>&1 | tee "$OUTPUT_DIR/pk-jdbc.txt"
+stop_app
diff --git a/passkeys/scripts/lib.sh b/passkeys/scripts/lib.sh
new file mode 100755
index 0000000..acdee70
--- /dev/null
+++ b/passkeys/scripts/lib.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Shared helpers. Sourced by every script in this directory.
+#
+# Two traps are baked in here because both have cost real time:
+#
+# * never `pkill -f spring-boot` - the pattern matches the shell that is running this
+# script and kills it. Kill by main class instead, which is what stop_app does.
+# * `mvn -o` cannot run the Boot plugin until one online build has cached it, so the first
+# run of run.sh is deliberately not offline.
+set -euo pipefail
+
+MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+OUTPUT_DIR="$(cd "$MODULE_DIR/.." && pwd)/docs/output"
+MAIN_CLASS="PasskeysDemoApplication"
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+APP_LOG="${APP_LOG:-/tmp/passkeys-demo.log}"
+
+mkdir -p "$OUTPUT_DIR"
+
+stop_app() {
+ for pid in $(ps -eo pid,cmd | grep "[${MAIN_CLASS:0:1}]${MAIN_CLASS:1}" | awk '{print $1}'); do
+ kill -9 "$pid" 2>/dev/null || true
+ done
+ # ss -lptn sometimes reports the port with no PID, so a port-based kill silently does
+ # nothing and the stale process keeps serving. Wait for the port to actually close.
+ for _ in $(seq 1 20); do
+ curl -sf -o /dev/null "$BASE_URL/health" || return 0
+ sleep 0.5
+ done
+}
+
+start_app() {
+ local profiles="${1:-}"
+ stop_app
+ cd "$MODULE_DIR"
+ local args=(-B org.springframework.boot:spring-boot-maven-plugin:run)
+ [ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
+ setsid nohup mvn "${args[@]}" > "$APP_LOG" 2>&1 < /dev/null &
+ for _ in $(seq 1 90); do
+ curl -sf -o /dev/null "$BASE_URL/health" && { echo "started${profiles:+ with profiles: $profiles}"; return 0; }
+ sleep 2
+ done
+ echo "the application did not come up; see $APP_LOG" >&2
+ tail -40 "$APP_LOG" >&2
+ return 1
+}
+
+classpath() {
+ cd "$MODULE_DIR"
+ [ -f target/deps.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/deps.txt -DincludeScope=runtime
+ echo "target/classes:$(cat target/deps.txt)"
+}
+
+ceremony() {
+ cd "$MODULE_DIR"
+ java --class-path "$(classpath)" tools/PasskeyCeremony.java "$@"
+}
+
+header() {
+ echo "=============================================================================="
+ echo "$1"
+ echo "=============================================================================="
+}
diff --git a/passkeys/scripts/origin.sh b/passkeys/scripts/origin.sh
new file mode 100755
index 0000000..b9039cb
--- /dev/null
+++ b/passkeys/scripts/origin.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# What a phishing attempt looks like from the relying party's side, in both ceremonies.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "Registration from a disallowed origin"
+ ceremony wrong-origin
+ echo
+ echo "--- what the server logged ---"
+ grep -A4 -m1 -E 'BadOriginException|InconsistentClientDataTypeException' "$APP_LOG" || tail -5 "$APP_LOG"
+
+ header "Assertion from a disallowed origin"
+ ceremony wrong-origin-login
+} 2>&1 | tee "$OUTPUT_DIR/pk-origin.txt"
+stop_app
diff --git a/passkeys/scripts/ott-fallback.sh b/passkeys/scripts/ott-fallback.sh
new file mode 100755
index 0000000..6694a9e
--- /dev/null
+++ b/passkeys/scripts/ott-fallback.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# The one-time token path: generate, redeem, then try to redeem again.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "One-time token login - the way in, and the way back after a lost device"
+ ceremony ott
+} 2>&1 | tee "$OUTPUT_DIR/pk-ott.txt"
+stop_app
diff --git a/passkeys/scripts/run-all.sh b/passkeys/scripts/run-all.sh
new file mode 100755
index 0000000..6afd53e
--- /dev/null
+++ b/passkeys/scripts/run-all.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Regenerates every docs/output/pk-*.txt file in this repository.
+#
+# Timings and instants differ between runs; nothing else should.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+cd "$MODULE_DIR"
+mvn -B -q compile
+for script in ceremony counter user-verification origin attestation duplicate bootstrap step-up ott-fallback jdbc filters test-run; do
+ echo ">>> scripts/$script.sh"
+ "./scripts/$script.sh" > /dev/null
+done
+stop_app
+ls -la "$OUTPUT_DIR"/pk-*.txt
diff --git a/passkeys/scripts/run.sh b/passkeys/scripts/run.sh
new file mode 100755
index 0000000..5be0e2b
--- /dev/null
+++ b/passkeys/scripts/run.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Start the demo with the given profiles.
+#
+# ./scripts/run.sh defaults: rpId localhost, UV preferred, in memory
+# ./scripts/run.sh uvrequired user verification REQUIRED on both ceremonies
+# ./scripts/run.sh attestationdirect ask for DIRECT attestation and watch nothing change
+# ./scripts/run.sh badorigin relying party expects an origin the client won't send
+# ./scripts/run.sh jdbc credentials in H2 using Spring Security's own DDL
+# ./scripts/run.sh trace every WebAuthn log line the framework emits
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "${1:-}"
+echo "log: $APP_LOG"
+echo "browser: $BASE_URL/login (user/password, then $BASE_URL/webauthn/register)"
diff --git a/passkeys/scripts/step-up.sh b/passkeys/scripts/step-up.sh
new file mode 100755
index 0000000..dc54e47
--- /dev/null
+++ b/passkeys/scripts/step-up.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# The same user, three sessions, one endpoint that only one of them can reach.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+start_app "" > /dev/null
+{
+ header "FactorGrantedAuthority - password, magic link and passkey are not interchangeable"
+ ceremony stepup
+} 2>&1 | tee "$OUTPUT_DIR/pk-step-up.txt"
+stop_app
diff --git a/passkeys/scripts/test-run.sh b/passkeys/scripts/test-run.sh
new file mode 100755
index 0000000..7890838
--- /dev/null
+++ b/passkeys/scripts/test-run.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# The contract tests. These need no running server.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+cd "$MODULE_DIR"
+mvn -B test 2>&1 | sed -n '/T E S T S/,$p' | tee "$OUTPUT_DIR/pk-test-run.txt"
diff --git a/passkeys/scripts/user-verification.sh b/passkeys/scripts/user-verification.sh
new file mode 100755
index 0000000..6578040
--- /dev/null
+++ b/passkeys/scripts/user-verification.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# The same authenticator, with the UV flag clear, against both settings.
+source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+{
+ header "userVerification PREFERRED - the default"
+ start_app "" > /dev/null
+ ceremony no-uv
+ stop_app
+
+ header "userVerification REQUIRED - the uvrequired profile"
+ start_app uvrequired > /dev/null
+ ceremony no-uv
+ stop_app
+} 2>&1 | tee "$OUTPUT_DIR/pk-user-verification.txt"
diff --git a/passkeys/src/main/java/com/ankurm/passkeys/PasskeysDemoApplication.java b/passkeys/src/main/java/com/ankurm/passkeys/PasskeysDemoApplication.java
new file mode 100644
index 0000000..66ead68
--- /dev/null
+++ b/passkeys/src/main/java/com/ankurm/passkeys/PasskeysDemoApplication.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
+ * repository root.
+ */
+package com.ankurm.passkeys;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Passkeys and WebAuthn on Spring Security 7.1 / Spring Boot 4.1.
+ *
+ * Runs on port 8080 with a relying party id of {@code localhost}, because {@code localhost}
+ * is the one origin browsers treat as a secure context without TLS. Every other host needs
+ * HTTPS before {@code navigator.credentials.create()} will even be offered.
+ *
+ *
Nothing here is auto-configured. Spring Boot 4.1 ships no WebAuthn auto-configuration and
+ * no {@code spring.security.webauthn.*} properties - see docs/passkeys/01-versions.md.
+ */
+@SpringBootApplication
+public class PasskeysDemoApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(PasskeysDemoApplication.class, args);
+ }
+
+}
diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/AppUsers.java b/passkeys/src/main/java/com/ankurm/passkeys/config/AppUsers.java
new file mode 100644
index 0000000..f404e67
--- /dev/null
+++ b/passkeys/src/main/java/com/ankurm/passkeys/config/AppUsers.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
+ * repository root.
+ */
+package com.ankurm.passkeys.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+
+/**
+ * Two users, in memory, with passwords - which is the first thing worth noticing about a
+ * passkeys demo.
+ *
+ *
{@code WebAuthnConfigurer.configure} throws {@code IllegalStateException: Missing
+ * UserDetailsService Bean} without one, and
+ * {@code Webauthn4JRelyingPartyOperations.createPublicKeyCredentialCreationOptions} throws
+ * {@code IllegalArgumentException: Authentication must be authenticated} unless the caller is
+ * already logged in. A passkey cannot be the first credential a user has; something else has
+ * to carry them to the point where they can register one. See
+ * docs/passkeys/06-the-bootstrap-problem.md.
+ */
+@Configuration
+public class AppUsers {
+
+ @Bean
+ UserDetailsService userDetailsService() {
+ UserDetails user = User.withDefaultPasswordEncoder()
+ .username("user")
+ .password("password")
+ .roles("USER")
+ .build();
+ UserDetails admin = User.withDefaultPasswordEncoder()
+ .username("admin")
+ .password("password")
+ .roles("USER", "ADMIN")
+ .build();
+ return new InMemoryUserDetailsManager(user, admin);
+ }
+
+}
diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/PersistenceConfig.java b/passkeys/src/main/java/com/ankurm/passkeys/config/PersistenceConfig.java
new file mode 100644
index 0000000..0248adf
--- /dev/null
+++ b/passkeys/src/main/java/com/ankurm/passkeys/config/PersistenceConfig.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
+ * repository root.
+ */
+package com.ankurm.passkeys.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository;
+import org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository;
+import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
+import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
+import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
+import org.springframework.security.web.webauthn.management.UserCredentialRepository;
+
+/**
+ * Where credentials live.
+ *
+ *
{@code WebAuthnConfigurer} will happily create {@code MapUserCredentialRepository} and
+ * {@code MapPublicKeyCredentialUserEntityRepository} for you if no beans exist. Declaring them
+ * explicitly costs nothing and buys two things: the diagnostics endpoint can read them, and
+ * the in-memory default stops being invisible. It is in-memory - every passkey your users
+ * registered disappears on restart, and they have no password to fall back on.
+ *
+ *
The {@code jdbc} profile switches to the JDBC repositories. Their schema is not created
+ * for you; {@code schema-jdbc.sql} in this module is Spring Security's own DDL, loaded through
+ * {@code spring.sql.init}. See docs/passkeys/09-persistence.md.
+ */
+@Configuration
+public class PersistenceConfig {
+
+ @Configuration
+ @Profile("!jdbc")
+ static class InMemory {
+
+ @Bean
+ PublicKeyCredentialUserEntityRepository userEntityRepository() {
+ return new MapPublicKeyCredentialUserEntityRepository();
+ }
+
+ @Bean
+ UserCredentialRepository userCredentialRepository() {
+ return new MapUserCredentialRepository();
+ }
+
+ }
+
+ @Configuration
+ @Profile("jdbc")
+ static class Jdbc {
+
+ @Bean
+ PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
+ return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
+ }
+
+ @Bean
+ UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
+ return new JdbcUserCredentialRepository(jdbc);
+ }
+
+ }
+
+}
diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java b/passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java
new file mode 100644
index 0000000..411c892
--- /dev/null
+++ b/passkeys/src/main/java/com/ankurm/passkeys/config/RelyingPartyConfig.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
+ * repository root.
+ */
+package com.ankurm.passkeys.config;
+
+import java.util.Set;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.web.webauthn.api.AttestationConveyancePreference;
+import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
+import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
+import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
+import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
+import org.springframework.security.web.webauthn.management.UserCredentialRepository;
+import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
+import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
+
+/**
+ * The relying party operations bean, built by hand so the defaults can be changed one at a
+ * time and the difference observed.
+ *
+ *
Exposing this bean at all is a decision with a consequence: from that point on the
+ * {@code rpId}, {@code rpName} and {@code allowedOrigins} in {@link SecurityConfig} are dead
+ * configuration. {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} looks for a bean
+ * of this type first and, when it finds one, never reads its own fields. That is why the
+ * values are repeated here rather than shared.
+ *
+ *
Profiles:
+ *
+ *
+ * - uvrequired - raises user verification from the default {@code PREFERRED} to
+ * {@code REQUIRED}, on both ceremonies. The default accepts a credential created and asserted
+ * with the UV flag clear, which means "something was touched" rather than "someone was
+ * verified".
+ * - attestationdirect - asks for {@code DIRECT} attestation instead of the default
+ * {@code NONE}. Registration still succeeds against an authenticator that sends
+ * {@code fmt: "none"} and an all-zero AAGUID, because the default
+ * {@code WebAuthnManager.createNonStrictWebAuthnManager()} verifies no attestation at all.
+ * Asking is not checking.
+ * - badorigin - the same relying party, told to expect a different origin. This is
+ * what a phishing attempt looks like from the server's side.
+ *
+ *
+ * @see docs/passkeys/05-defaults.md
+ */
+@Configuration
+@Profile({ "uvrequired", "attestationdirect", "badorigin" })
+public class RelyingPartyConfig {
+
+ @Bean
+ WebAuthnRelyingPartyOperations relyingPartyOperations(PublicKeyCredentialUserEntityRepository userEntities,
+ UserCredentialRepository userCredentials, @Value("${demo.rp-id:localhost}") String rpId,
+ @Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin,
+ @Value("${demo.user-verification-required:false}") boolean userVerificationRequired,
+ @Value("${demo.attestation-direct:false}") boolean attestationDirect) {
+
+ PublicKeyCredentialRpEntity rp = PublicKeyCredentialRpEntity.builder()
+ .id(rpId)
+ .name("ankurm passkeys demo")
+ .build();
+ Webauthn4JRelyingPartyOperations operations = new Webauthn4JRelyingPartyOperations(userEntities,
+ userCredentials, rp, Set.of(allowedOrigin));
+
+ if (userVerificationRequired) {
+ // Both halves have to be set. registerCredential() reads userVerification off the
+ // creation options; authenticate() reads it off the request options. Setting only
+ // one leaves the other ceremony at PREFERRED, which verifies nothing.
+ operations.setCustomizeCreationOptions((options) -> options
+ .authenticatorSelection(AuthenticatorSelectionCriteria.builder()
+ .userVerification(UserVerificationRequirement.REQUIRED)
+ .residentKey(ResidentKeyRequirement.REQUIRED)
+ .build()));
+ operations.setCustomizeRequestOptions(
+ (options) -> options.userVerification(UserVerificationRequirement.REQUIRED));
+ }
+
+ if (attestationDirect) {
+ operations
+ .setCustomizeCreationOptions((options) -> options.attestation(AttestationConveyancePreference.DIRECT));
+ }
+
+ return operations;
+ }
+
+}
diff --git a/passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java b/passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java
new file mode 100644
index 0000000..7995cd8
--- /dev/null
+++ b/passkeys/src/main/java/com/ankurm/passkeys/config/SecurityConfig.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
+ * repository root.
+ */
+package com.ankurm.passkeys.config;
+
+import com.ankurm.passkeys.ott.ConsoleOneTimeTokenHandler;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * The whole passkey configuration is the {@code webAuthn(..)} block. Everything else is
+ * scaffolding.
+ *
+ * Three things about this file are worth more than they look:
+ *
+ *
+ * - {@code rpId} and {@code allowedOrigins} are two separate settings that must agree. The
+ * rpId is the domain the credential is scoped to; the origin is the exact scheme, host and
+ * port the browser reports. {@code localhost} and {@code http://localhost:8080} agree.
+ * {@code localhost} and {@code http://127.0.0.1:8080} do not, and the failure arrives as a
+ * flat 401 - see docs/passkeys/07-failure-modes.md.
+ * - If a {@code WebAuthnRelyingPartyOperations} bean exists, the {@code rpId},
+ * {@code rpName} and {@code allowedOrigins} set here are silently ignored:
+ * {@code WebAuthnConfigurer.webAuthnRelyingPartyOperations} returns the bean and never reads
+ * its own fields. {@link RelyingPartyConfig} exposes such a bean under several profiles,
+ * which is precisely why the values are repeated there.
+ * - {@code oneTimeTokenLogin} is not decoration. Registering a passkey requires an existing
+ * authenticated session, so a passwordless system still needs a way in and a way back after a
+ * lost device.
+ *
+ *
+ * @see docs/passkeys/02-minimum-configuration.md
+ */
+@Configuration
+public class SecurityConfig {
+
+ private final String rpId;
+
+ private final String allowedOrigin;
+
+ public SecurityConfig(@Value("${demo.rp-id:localhost}") String rpId,
+ @Value("${demo.allowed-origin:http://localhost:8080}") String allowedOrigin) {
+ this.rpId = rpId;
+ this.allowedOrigin = allowedOrigin;
+ }
+
+ @Bean
+ SecurityFilterChain filterChain(HttpSecurity http, ConsoleOneTimeTokenHandler oneTimeTokenHandler)
+ throws Exception {
+ http.authorizeHttpRequests((requests) -> requests.requestMatchers("/", "/health")
+ .permitAll()
+ // Step-up. A session established by a magic link carries FACTOR_OTT; one
+ // established by a passkey carries FACTOR_WEBAUTHN. Both are ordinary
+ // authorities, so requiring a real passkey for a sensitive endpoint is one
+ // matcher - see docs/passkeys/06-the-bootstrap-problem.md.
+ .requestMatchers("/passkey-only")
+ .hasAuthority("FACTOR_WEBAUTHN")
+ .anyRequest()
+ .authenticated())
+ .formLogin(Customizer.withDefaults())
+ // The magic-link fallback. Without a OneTimeTokenGenerationSuccessHandler the
+ // context fails to start - Spring Security refuses to guess how to deliver a
+ // token. See docs/passkeys/08-one-time-token-fallback.md.
+ .oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(oneTimeTokenHandler))
+ .webAuthn((webAuthn) -> webAuthn.rpId(this.rpId)
+ .rpName("ankurm passkeys demo")
+ .allowedOrigins(this.allowedOrigin))
+ .logout(Customizer.withDefaults());
+ return http.build();
+ }
+
+}
diff --git a/passkeys/src/main/java/com/ankurm/passkeys/diag/CredentialDiagnostics.java b/passkeys/src/main/java/com/ankurm/passkeys/diag/CredentialDiagnostics.java
new file mode 100644
index 0000000..075960f
--- /dev/null
+++ b/passkeys/src/main/java/com/ankurm/passkeys/diag/CredentialDiagnostics.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2026 Ankur Mhatre. Licensed under the MIT License - see LICENSE at the
+ * repository root.
+ */
+package com.ankurm.passkeys.diag;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.servlet.Filter;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.FilterChainProxy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
+import org.springframework.security.web.webauthn.api.CredentialRecord;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
+import org.springframework.security.web.webauthn.management.UserCredentialRepository;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Prints the state that is otherwise invisible: what a stored {@code CredentialRecord}
+ * actually contains, and where the WebAuthn filters sit in the chain.
+ *
+ * {@code /diag/credentials} is the exhibit for the signature counter. The
+ * {@code signatureCount} it reports is the value the relying party persisted after the last
+ * assertion - which is not the value it compares the next assertion against. See
+ * docs/passkeys/10-signature-counter.md.
+ *
+ *
Delete this class before shipping. It reports credential ids and user handles to any
+ * authenticated caller.
+ */
+@RestController
+public class CredentialDiagnostics {
+
+ private final PublicKeyCredentialUserEntityRepository userEntities;
+
+ private final UserCredentialRepository userCredentials;
+
+ private final FilterChainProxy filterChainProxy;
+
+ public CredentialDiagnostics(PublicKeyCredentialUserEntityRepository userEntities,
+ UserCredentialRepository userCredentials, FilterChainProxy filterChainProxy) {
+ this.userEntities = userEntities;
+ this.userCredentials = userCredentials;
+ this.filterChainProxy = filterChainProxy;
+ }
+
+ @GetMapping("/diag/credentials")
+ public Map credentials(Authentication authentication) {
+ Map result = new LinkedHashMap<>();
+ result.put("principal", authentication.getName());
+ result.put("principalType", authentication.getClass().getSimpleName());
+ result.put("authorities", authentication.getAuthorities().stream().map(Object::toString).sorted().toList());
+
+ PublicKeyCredentialUserEntity userEntity = this.userEntities.findByUsername(authentication.getName());
+ if (userEntity == null) {
+ result.put("userEntity", null);
+ result.put("credentials", List.of());
+ return result;
+ }
+ result.put("userEntity", Map.of("id", userEntity.getId().toBase64UrlString(), "name", userEntity.getName(),
+ "displayName", userEntity.getDisplayName()));
+
+ List