Passkeys and WebAuthn with Spring Security 7: Passwordless Login That Actually Works
Passkeys on Spring Security 7.1 and Spring Boot 4.1: the registration and authentication ceremonies, WebAuthnRelyingPartyOperations, and the one-time-token fallback that solves the bootstrap problem. Every transcript comes from a real run driven by a software authenticator — including the run that shows the signature counter being written on every login and compared against on none, user verification being optional by default, and a request for DIRECT attestation being satisfied by an authenticator that attests to nothing.
Every passwordless article opens the same way: passwords are terrible, passkeys are wonderful, here is a code snippet. Then you paste the snippet, it works on localhost, and three weeks later you are trying to explain to a support queue why a user who dropped their phone in a canal cannot get back into their account.
The Spring Security part really is small. One dependency, one DSL block, one UserDetailsService, and you have working passkey login with pages the framework generates for you. That part takes an afternoon.
The rest of this article is about the other parts — because a passkey cannot be a user’s first credential, because three of the defaults are weaker than they look, and because there is one field that Spring Security writes to your database on every single login and never reads back.
Verified against these versions. Spring Boot 4.1.1, Spring Security 7.1.1 (GA 20 August 2026), spring-security-webauthn7.1.1, WebAuthn4J 0.31.9.RELEASE, Spring Framework 7.0.9, Jackson 3.1.5, Tomcat 11.0.24, Temurin JDK 25.0.4.1+1.
Every transcript below came out of a real run. The companion repository contains a software authenticator that produces genuine CBOR attestation objects and genuine ES256 signatures, so both WebAuthn ceremonies execute in CI with no browser and no hardware key — which is also how the more uncomfortable findings here were confirmed.
Part
If you
You get
1 — The mental model
have never shipped WebAuthn
what a passkey actually is, the smallest configuration that works, and the two ceremonies on the wire
2 — How it works, and how to debug it
have it working and want to keep it working
the filter chain, the bootstrap problem, the one-time-token fallback, and every failure mode with its symptom
3 — What the defaults do not do
are about to put this in front of real users
user verification, attestation, and the signature counter that is stored but never checked
Part 1 — The mental model
A passkey is a key pair with an origin stapled to it
Strip away the ceremony names and WebAuthn is three ideas.
The authenticator — a phone, a laptop’s secure enclave, a USB key, a password manager — generates a key pair. The private half never leaves it. The public half goes to your server.
Every credential is bound to a relying party id, which is a domain. A credential minted for bank.example is only ever offered on bank.example. The browser, not your JavaScript, decides that.
Every signature covers the origin the browser reports. So even if a phishing page somehow obtained an assertion, the origin inside it would not match and your server would refuse it.
That last pair is the entire value proposition. A password can be typed into the wrong box. A passkey structurally cannot.
The rpId and the origin are two settings, not one. The rpId is a bare domain (localhost, example.com); the allowed origin is the exact scheme-host-port string the browser writes into client data (http://localhost:8080). http://127.0.0.1:8080 is a different origin from http://localhost:8080 even though both reach the same server, and localhost is not a registrable suffix of 127.0.0.1. Getting these two out of step is the single most common way to produce a configuration that looks correct and returns a flat 401.
The dependency that is easy to miss
Passkey support landed in Spring Security 6.4, and at that time it lived inside spring-security-web — already on the classpath of every Boot application using spring-boot-starter-security. As of Spring Security 7.0 it does not.
The classes moved into a new artifact. The package names did not change — everything is still org.springframework.security.web.webauthn.* — which is what makes the upgrade awkward. Your imports keep compiling against a warm local repository and fail on a clean build, and the http.webAuthn(..) DSL method lives in spring-security-config, which is present either way.
No version element: spring-boot-dependencies:4.1.1 manages it, at 7.1.1.
Spring Boot does not auto-configure any of this. Grepping spring-boot-autoconfigure-4.1.1.jar and spring-boot-security-4.1.1.jar for webauthn returns nothing. There are no spring.security.webauthn.* properties. Every relying party setting is Java configuration, because Java configuration is the only option.
Plus a UserDetailsService bean — without one, WebAuthnConfigurer.configure throws IllegalStateException: Missing UserDetailsService Bean at startup. A passkey authenticates a credential; Spring still needs somewhere to look up the authorities that belong to the username that credential resolves to.
That gives you six endpoints and a working browser flow, including a generated registration page at /webauthn/register.
Method
Path
What it does
POST
/webauthn/register/options
issues a challenge and the creation options — requires an authenticated session
POST
/webauthn/register
verifies the attestation and stores a CredentialRecord
DELETE
/webauthn/register/{id}
removes a credential, guarded so users cannot delete each other’s
POST
/webauthn/authenticate/options
issues a challenge and the request options
POST
/login/webauthn
verifies the assertion and creates the session
GET
/webauthn/register
the built-in registration page
The two ceremonies
Both flows have the same shape: the server issues a challenge, the authenticator answers it, the server verifies the answer.
Here are the real creation options from a live run:
Every value there is a Spring Security default, and every one is a decision. residentKey: required is what makes usernameless login possible later. pubKeyCredParams offers EdDSA, ES256 and RS256, in that order. user.id is a random 32-byte handle, deliberately not the username — it is stored on the authenticator and syncs to the user’s other devices.
Note the first two values.attestation: none and userVerification: preferred look like sensible middle-ground defaults, and Part 3 is largely about what they actually mean.
And the authentication side:
$ POST /webauthn/authenticate/options
HTTP 200
{"allowCredentials":[],"challenge":"18HVET3lRrveXvRdKS1K5k2Ji_3nqm1Krx_l3ZOB0wU",
"extensions":{},"rpId":"localhost","timeout":300000,"userVerification":"preferred"}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
allowCredentials is empty because nobody is logged in yet, so the browser offers whatever discoverable credentials it holds for localhost. That empty array is the usernameless login everyone means when they say “passkey”, and it only works because residentKey defaulted to required at registration. Two settings, one decision, made in two places.
The catch you hit in the first hour
Read the registration flow again and notice what it assumes. POST /webauthn/register/optionsrequires an authenticated session. It has to: the options object contains user.id and user.name, and there is nobody to bind the credential to until somebody has logged in.
$ POST /webauthn/register/options (anonymous)
HTTP 400
(empty body)
So a passkey can never be a user’s first credential. Something else always carries them in. That is not a Spring Security limitation, it is the shape of the protocol, and it is the thing that turns “add passkeys” from an afternoon into a project. Part 2 is where that gets solved.
Part 2 — How it works, and how to debug it
Where the filters land
Four WebAuthn filters and two one-time-token filters get installed, and where they sit explains several behaviours that otherwise look arbitrary. This is the live chain, printed from FilterChainProxy in the running application:
Three things fall out of that ordering.
CsrfFilter at position 5 applies to everything below it. All five WebAuthn endpoints are POSTs that change server state — they store a challenge in the session — so every one of them needs a CSRF token. A front end that fetches options without one gets a 403 and no explanation.
The two options filters sit at 21 and 22, beforeAuthorizationFilter at 23. So /webauthn/register/options is answered without an authorization check and enforces its own requirement by throwing, which is why the anonymous call in Part 1 returned a bare 400 rather than a 401 or a redirect to the login page.
WebAuthnRegistrationFilter sits at 24, after authorization. It has no exception handling of its own.
Every registration failure is an HTTP 500
This is the first thing that confuses people, and it is worth stating plainly. Whatever WebAuthn4J throws propagates out of WebAuthnRegistrationFilter and Boot’s error page turns it into a 500. Three completely different mistakes, three identical responses:
$ POST /webauthn/register # origin mismatch
HTTP 500
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
$ POST /webauthn/register # userVerification REQUIRED, UV flag clear
HTTP 500
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
$ POST /webauthn/register # credential id already registered
HTTP 500
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/webauthn/register"}
Authentication fails differently, and no more helpfully. 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 never reaches the browser.
Read the server log, not the HTTP response. Neither ceremony tells the client anything useful, by design in one case and by omission in the other. Turn on logging.level.com.webauthn4j: DEBUG before you start guessing — the exception message is always specific and always correct, and it is the only place the answer exists.
The failure table
Symptom
Cause
What the log says
400, empty body, on /webauthn/register/options
nobody logged in, or the session expired
IllegalArgumentException: Authentication must be authenticated
403 on any /webauthn/** POST
missing CSRF token
CsrfFilter, position 5
500 on /webauthn/register
origin mismatch
BadOriginException: The collectedClientData origin '…' doesn't match expected: …
500 on /webauthn/register
UV required, authenticator did not verify
UserNotVerifiedException: … UV flag in authenticatorData is not set
500 on /webauthn/register
credential id already registered
IllegalArgumentException: Credential with id … already exists
401, empty body, on /login/webauthn
bad origin, bad signature, unknown credential id, or a null stored attestation object
BadCredentialsException wrapping the real cause
create() never prompts
not a secure context — plain HTTP on anything but localhost
browser console, not the server
registration fails intermittently
the challenge lives in the HttpSession; no sticky sessions behind the load balancer
nothing, which is the problem
credentials vanish on restart
MapUserCredentialRepository, the silent default
nothing
The origin check, watched from the server side
In a real browser you cannot reach this failure. The browser writes the origin itself and the credential is scoped to an rpId, so a phishing page never obtains an assertion to send. The companion repository’s software authenticator can lie about its origin precisely so the server-side half can be observed:
origin sent by the client: http://evil.localhost:8080
origin allowed by the relying party: 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)
That is the phishing defence working. It is worth seeing once, because everything else in this article is a caveat and this part is not.
Solving the bootstrap problem
Back to the thing Part 1 ended on: a passkey cannot be a user’s first credential. Something has to carry them to the point where they can register one.
Route
Good for
What it costs
One-time token by email or SMS
new signups, and recovery after a lost device
you now own a delivery channel and all of its failure modes
Existing password
adding passkeys to an application that already has users
the password stays, so the phishing surface stays
Federated login
consumer products that already federate
the identity provider becomes your recovery story
Support desk
enterprises with an identity-proofing process already
expensive, and now the desk is the attack surface
Spring Security ships the first one, and it is one line of DSL:
There is no default handler and there cannot be a sensible one — Spring has no idea whether you send email, SMS or a push notification. Omit the bean and the context fails to start, which is the correct decision and also the first error everybody hits.
POST /ott/generate -> HTTP 302, Location: http://localhost:8080/login/ott
token delivered out of band: 3203202f-7e17-4f8b-a613-0d64831c3530
POST /login/ott -> HTTP 302, Location: http://localhost:8080/
$ GET /me
{"name":"user","authenticationType":"OneTimeTokenAuthentication",
"authorities":["FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=..]","ROLE_USER"]}
=== the same token, a second time ===
POST /login/ott -> HTTP 302, Location: http://localhost:8080/login?error
There is no rate limiting on /ott/generate, and none ships. The endpoint will happily send somebody a hundred magic links, which is a nuisance for them and a reputation problem for your sending domain. Put a limiter in front of it before this goes anywhere near production.
On the other hand, it does not leak account existence. InMemoryOneTimeTokenService.generate has no UserDetailsService, so it cannot check whether the user exists and does not try — an unknown username produces a byte-identical response and a token that simply fails at redemption. That is a real property worth preserving; spring-security#16483 proposes changing it, and it would be easy to fix the storage waste and open an enumeration hole in the same commit.
Not all sessions are equal
The magic link gets the user in. It should not get them everything. Spring Security 7 expresses this with FactorGrantedAuthority, and the useful part is that these are ordinary authorities:
password session -> GET /passkey-only: HTTP 302
Location: /login?factor.type=webauthn&factor.reason=missing
one-time-token session -> GET /passkey-only: HTTP 302
Location: /login?factor.type=webauthn&factor.reason=missing
passkey session -> GET /passkey-only: HTTP 200
{"ok":"this endpoint required FACTOR_WEBAUTHN"}
Note that the denial is a redirect rather than a 403, and that the redirect says which factor is missing. WebAuthnConfigurer.init registers defaultDeniedHandlerForMissingAuthority(.., FactorGrantedAuthority.WEBAUTHN_AUTHORITY) with a LoginUrlAuthenticationEntryPoint, so “you are authenticated but not with a passkey” becomes “go and use your passkey”. Those query parameters are generated for you and are not mentioned anywhere on the passkeys reference page. A login page that reads factor.reason=missing can say something useful instead of “access denied”.
This is the lever that makes an email-based recovery path defensible: recovery gets you in, and it does not get you a password change, a new passkey registration, or a payout.
The bean that silently disables your configuration
One trap before Part 3, because it wastes an afternoon when it bites.
If a WebAuthnRelyingPartyOperations bean exists it is used as is, and the rpId, rpName and allowedOrigins you set on the DSL are never read. Everything still compiles and still starts, pointed at whatever the bean was constructed with. You will define such a bean the moment you want to change any default — which is the whole of Part 3 — so define it knowing that your DSL settings have just become decorative.
Part 3 — What the defaults do not do
Remember the two values from the options object in Part 1: attestation: none and userVerification: preferred. Both are reasonable. Both are weaker than they read.
userVerification is “preferred”, which means optional
Webauthn4JRelyingPartyOperations builds its selection criteria like this:
PREFERRED is not REQUIRED, so the flag is not checked. An authenticator that answers with the UV bit clear registers and logs in without complaint:
$ POST /webauthn/register
HTTP 200
{"success":true}
$ GET /diag/credentials
{"credentials":[{"label":"no-uv", ... "signatureCount":0,"uvInitialized":false, ...}]}
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
uvInitialized: false is recorded faithfully in the database. Nothing then consults it.
What you have is a single-factor credential: possession of the authenticator, with no evidence that the person holding it is the enrolled user. For a consumer site that is an acceptable trade — it is roughly what a password gives you, minus the phishing. If the passkey is your second factor, it is not, and the fix is one setting on both ceremonies:
com.webauthn4j.verifier.exception.UserNotVerifiedException:
Verifier is configured to check user verified, but UV flag in authenticatorData is not set.
Two ways to get this half-right, both silent. Setting only customizeCreationOptions means registration is verified and every subsequent login is not, forever — and registration looking correct is what makes it hard to spot. And AuthenticatorSelectionCriteria.builder() has no copy constructor, so overriding userVerification means restating residentKey(REQUIRED) too. Forget it and you quietly drop to non-discoverable credentials, at which point usernameless login stops working and nobody can tell you why.
Asking for attestation is not verifying attestation
Switch the default from NONE to DIRECT and the options object changes exactly as you would hope:
That factory installs NullFIDOU2FAttestationStatementVerifier, NullPackedAttestationStatementVerifier, NullTPMAttestationStatementVerifier, NullAndroidKeyAttestationStatementVerifier, NullAndroidSafetyNetAttestationStatementVerifier, NullAppleAnonymousAttestationStatementVerifier, a NullCertPathTrustworthinessVerifier and a NullSelfAttestationTrustworthinessVerifier. Every component that could check an attestation statement is a null object.
WebAuthn4J 0.31.9 does not ship a strict counterpart to switch to. WebAuthnManager has exactly two static factories — createNonStrictWebAuthnManager() and its ObjectConverter overload — so a strict manager has to be assembled from real verifiers plus a TrustAnchorRepository by hand and installed with setWebAuthnManager. That is a project, and it is only worth starting if you have an enterprise reason to care which model of key your users hold. For most products attestation is the wrong thing to spend effort on. Just know that the direct in your options object is currently decorative.
The signature counter is stored on every login and compared against on none
This is the one worth the length.
WebAuthn’s signature counter exists to detect a cloned authenticator. A hardware key increments a monotonic counter every time it signs, so if the relying party ever sees a counter it has already seen, two copies of the private key are in circulation.
Spring Security persists that counter faithfully. It never reads it back.
Here is a real run: one credential, three logins with counters 1, 2 and 3, checking what the server stored after each — and then a fourth login replaying a counter of 1.
--- assertion 1, authenticator signCount = 1
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
stored signatureCount now: 1
--- assertion 2, authenticator signCount = 2
stored signatureCount now: 2
--- assertion 3, authenticator signCount = 3
stored signatureCount now: 3
=== Replaying a stale counter. A cloned key would look exactly like this ===
--- assertion 4, authenticator signCount = 1 (lower than the stored 3)
$ POST /login/webauthn
HTTP 200
{"authenticated":true,"redirectUrl":"/"}
replayed a counter of 1 after the relying party had stored 3: HTTP 200
The stored value walks 1, 2, 3 exactly as you would want. Then a counter of 1 is accepted.
WebAuthn4J is not at fault here — it implements the check, line by line against the specification:
long presentedSignCount = authenticatorData.getSignCount();
long storedSignCount = authenticator.getCounter();
if (presentedSignCount > 0 || storedSignCount > 0) {
if (presentedSignCount > storedSignCount) { ... }
else { maliciousCounterValueHandler.maliciousCounterValueDetected(authenticationObject); }
}
and the default handler throws MaliciousCounterValueException with the message “Malicious counter value is detected. Cloned authenticators exist in parallel.”
The gap is in what authenticator.getCounter() returns. Spring reconstructs the WebAuthn4J credential record on every login from the stored attestation object:
and that constructor takes the counter from the attestation object’s authenticator data:
super(attestationObject.getAuthenticatorData().getAttestedCredentialData(),
attestationObject.getAttestationStatement(),
attestationObject.getAuthenticatorData().getSignCount(), // <-- the counter
attestationObject.getAuthenticatorData().getExtensions());
The attestation object was captured at registration. Its counter is frozen at whatever the authenticator reported then, which is almost always 0. So the signature_count column is written on every login and read by nothing, and the comparison degrades from “greater than the counter I saw last time” to “greater than the counter I saw at registration”. For a credential that registered at 0 — that is, nearly all of them — every assertion above zero passes forever, including a replay of one you have already seen.
How much does this actually matter?
Less than the length above suggests, and it is worth being precise about why rather than filing it as a scandal.
The specification treats a mismatch as a signal, not a verdict. WebAuthn Level 3 does make the comparison a step in the assertion verification procedure, but it describes a non-increasing counter as “a signal, but not proof, that the authenticator may be cloned” — it might equally be a malfunctioning authenticator, or assertions processed out of order — and tells relying parties to “evaluate their own operational characteristics and incorporate this information into their risk scoring”. Spring Security sits at the permissive end of that range rather than outside it.
Modern passkeys have no counter to check. A credential synced through iCloud Keychain, Google Password Manager or 1Password exists on several devices by design, so a monotonic counter is meaningless across them and those authenticators report 0 permanently. For a consumer application where most credentials are syncable, counter checking would detect nothing and risk false positives on real users.
It still matters if you enrol hardware keys. In an enterprise deployment with FIDO2 keys the counter is real, it increments, and the check is a genuine clone detector — and you do not have it. The hazard is not a hole so much as a control you may believe you have, made more convincing by a signature_count column that fills in correctly.
If you need it, nothing blocks you. Wrap the operations bean: read findByCredentialId(id).getSignatureCount() before delegating to authenticate, and compare it against the value the delegate has just written. Reject the login if it did not increase and either value was non-zero — that reproduces the specification’s rule using the column Spring already maintains. Alert rather than block if syncable passkeys are in the mix; a permanent 0 must not lock anybody out.
Persistence, and the column you must not drop
Two repositories, and the default for both is a HashMap. WebAuthnConfigurer.configure constructs MapUserCredentialRepository and MapPublicKeyCredentialUserEntityRepository when no beans exist, and logs nothing about it. In a passwordless application that is a trap with teeth: restart the process and every user’s only credential is gone, and by design they have no password to fall back on.
The JDBC implementations exist and are two beans. What is not obvious is that nothing creates the tables, and the DDL is not where the Javadoc implies. The schema files stayed in spring-security-web when the classes moved out in 7.0, along with spring-security-webauthn.js:
And one column in that schema is nullable in the DDL and mandatory in practice. authenticate re-parses attestation_object on every single login to recover the COSE key:
Bytes attestationObject = credentialRecord.getAttestationObject();
Assert.notNull(attestationObject, "attestationObject cannot be null");
So a custom UserCredentialRepository that stores the public key but drops the attestation object — a reasonable-looking optimisation, since the public key has its own column right there — produces credentials that register perfectly and can never log in. The symptom is a bare 401. That same re-parse is the mechanism behind the signature counter above.
The rest of the long tail
Each of these has a chapter in the companion repository rather than a paragraph here:
Building a software authenticator: authenticator data layout, hand-rolled CBOR, and the BigInteger padding bug that costs an afternoon — 04 — A software authenticator
Deleting credentials, and refusing to delete the last one — 09 — Persistence
excludeCredentials, and what a client that ignores it gets — pk-duplicate.txt
A NullPointerException on an unrecognised authenticator transport such as cable — spring-security#19366
If your recovery path is an email magic link and nothing else, you have not raised your security level — you have moved it into your users’ inboxes. A phishing-resistant credential behind a phishable reset is a phishable account with extra steps. Fix recovery first: require two passkeys where you can, rate-limit and age-limit recovery, notify every address on file when a credential is registered, and use FACTOR_WEBAUTHN to keep recovery sessions away from anything that matters. The passkeys will still be there afterwards.
The Spring Security part is genuinely small — one dependency, one DSL block, one UserDetailsService, and generated pages you can demo before writing any JavaScript. An afternoon is a fair estimate.
The rest is not small: a delivery channel for one-time tokens, a credential management UI, recovery policy and rate limits, a real front end, persistence and backups, and a fallback for clients that cannot do WebAuthn at all. Spring Security 7.1 has taken the protocol off your list of problems and left you with every product problem. That is a good trade. It is just not the same as being finished.
Do it if you have consumer users and a credential-stuffing problem, if you already federate, and if you can persuade users to enrol two credentials. Wait if you cannot run HTTPS in every environment including staging, if you are behind a load balancer without sticky sessions or shared session storage, or if you were counting on clone detection.
Further reading
The companion chapters — eleven of them, with twelve captured transcripts, all regenerated by one script
No Comments yet!