1
0
Files
spring-auth-demo/docs/passkeys/03-the-two-ceremonies.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

4.9 KiB

← 02 — The minimum configuration · index · next: 04 — A software authenticator

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):

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

← 02 — The minimum configuration · index · next: 04 — A software authenticator