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