1
0
Files
spring-auth-demo/docs/passkeys/05-defaults.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.7 KiB

← 04 — A software authenticator · index · next: 06 — The bootstrap problem

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:

AuthenticatorSelectionCriteria.builder()
    .userVerification(UserVerificationRequirement.PREFERRED)
    .residentKey(ResidentKeyRequirement.REQUIRED)
    .build();

and both registerCredential and authenticate decide whether to enforce UV like this:

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:

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

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:

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

$ POST /webauthn/register
HTTP 200
{"success":true}

The reason is the manager. Webauthn4JRelyingPartyOperations initialises with:

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.

← 04 — A software authenticator · index · next: 06 — The bootstrap problem