1
0
Files
spring-auth-demo/docs/passkeys/07-failure-modes.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

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

  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)
  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 · index · next: 08 — The one-time-token fallback