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.
5.1 KiB
← 06 — The bootstrap problem · index · next: 08 — The one-time-token fallback
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:
./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,
pk-user-verification.txt,
pk-duplicate.txt):
$ POST /webauthn/register
HTTP 500
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
Authentication is different. WebAuthnAuthenticationProvider catches everything:
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 |
NullPointerException during registration |
an authenticator transport Spring does not know, e.g. cable |
spring-security#19366 |
| WebAuthn breaks under Spring Session with Redis | creation options serialisation | spring-security#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, 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
- Is the rpId a domain, and does it match or registrably-suffix the origin's host?
- Is the allowed origin the exact string, with scheme and port?
- Is there a
WebAuthnRelyingPartyOperationsbean quietly overriding the DSL? (02) - Is the request a POST with a CSRF token?
- Is the session the same one that received the challenge? The default
PublicKeyCredentialCreationOptionsRepositoryisHttpSession-backed, so a load balancer without sticky sessions breaks registration and nothing else. - Turn on
logging.level.com.webauthn4j: DEBUGand read the actual exception.
← 06 — The bootstrap problem · index · next: 08 — The one-time-token fallback