Skip to main content

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-webauthn 7.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.
PartIf youYou get
1 — The mental modelhave never shipped WebAuthnwhat a passkey actually is, the smallest configuration that works, and the two ceremonies on the wire
2 — How it works, and how to debug ithave it working and want to keep it workingthe filter chain, the bootstrap problem, the one-time-token fallback, and every failure mode with its symptom
3 — What the defaults do not doare about to put this in front of real usersuser 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.
$ unzip -l spring-security-web-6.5.11.jar | grep -c -i webauthn
135

$ unzip -l spring-security-web-7.1.1.jar | grep -i webauthn
    10036  org/springframework/security/spring-security-webauthn.js

$ unzip -l spring-security-webauthn-7.1.1.jar | grep -c -i webauthn
139
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.
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-webauthn</artifactId>
</dependency>
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.

The smallest thing that works

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests((requests) -> requests.anyRequest().authenticated())
        .formLogin(Customizer.withDefaults())
        .webAuthn((webAuthn) -> webAuthn
            .rpId("localhost")
            .rpName("ankurm passkeys demo")
            .allowedOrigins("http://localhost:8080"));
    return http.build();
}
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.
MethodPathWhat it does
POST/webauthn/register/optionsissues a challenge and the creation options — requires an authenticated session
POST/webauthn/registerverifies the attestation and stores a CredentialRecord
DELETE/webauthn/register/{id}removes a credential, guarded so users cannot delete each other’s
POST/webauthn/authenticate/optionsissues a challenge and the request options
POST/login/webauthnverifies the assertion and creates the session
GET/webauthn/registerthe 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.
Registration — navigator.credentials.create() Relying party POST /webauthn/ register/options challenge, rp, user Authenticator generates a P-256 key pair; private half never leaves Relying party POST /webauthn/ register CredentialRecord challenge attestationObject clientDataJSON Authentication — navigator.credentials.get() Relying party POST /webauthn/ authenticate/options challenge, rpId Authenticator signs authData || SHA-256(clientData) with the private half Relying party POST /login/webauthn verify vs stored COSE public key challenge signature The challenge is stored in the HttpSession between the options call and the ceremony call, which is why both are POSTs and both need a CSRF token. The origin is written into clientDataJSON by the browser, not by your page. That single fact is the entire phishing defence. The private key is used, never transmitted. The relying party only ever sees a public key and signatures over challenges it issued itself.
Here are the real creation options from a live run:
$ POST /webauthn/register/options
HTTP 200
{"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"}}
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/options requires 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:
 1 DisableEncodeUrlFilter
 2 WebAsyncManagerIntegrationFilter
 3 SecurityContextHolderFilter
 4 HeaderWriterFilter
 5 CsrfFilter
 6 LogoutFilter
 7 GenerateOneTimeTokenFilter
 8 UsernamePasswordAuthenticationFilter
 9 OneTimeTokenAuthenticationFilter
10 DefaultResourcesFilter
11 DefaultResourcesFilter
12 DefaultResourcesFilter
13 DefaultLoginPageGeneratingFilter
14 DefaultLogoutPageGeneratingFilter
15 DefaultOneTimeTokenSubmitPageGeneratingFilter
16 WebAuthnAuthenticationFilter
17 RequestCacheAwareFilter
18 SecurityContextHolderAwareRequestFilter
19 AnonymousAuthenticationFilter
20 ExceptionTranslationFilter
21 PublicKeyCredentialCreationOptionsFilter
22 PublicKeyCredentialRequestOptionsFilter
23 AuthorizationFilter
24 WebAuthnRegistrationFilter
25 DefaultWebAuthnRegistrationPageGeneratingFilter
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, before AuthorizationFilter 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

SymptomCauseWhat the log says
400, empty body, on /webauthn/register/optionsnobody logged in, or the session expiredIllegalArgumentException: Authentication must be authenticated
403 on any /webauthn/** POSTmissing CSRF tokenCsrfFilter, position 5
500 on /webauthn/registerorigin mismatchBadOriginException: The collectedClientData origin '…' doesn't match expected: …
500 on /webauthn/registerUV required, authenticator did not verifyUserNotVerifiedException: … UV flag in authenticatorData is not set
500 on /webauthn/registercredential id already registeredIllegalArgumentException: Credential with id … already exists
401, empty body, on /login/webauthnbad origin, bad signature, unknown credential id, or a null stored attestation objectBadCredentialsException wrapping the real cause
create() never promptsnot a secure context — plain HTTP on anything but localhostbrowser console, not the server
registration fails intermittentlythe challenge lives in the HttpSession; no sticky sessions behind the load balancernothing, which is the problem
credentials vanish on restartMapUserCredentialRepository, the silent defaultnothing

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.
RouteGood forWhat it costs
One-time token by email or SMSnew signups, and recovery after a lost deviceyou now own a delivery channel and all of its failure modes
Existing passwordadding passkeys to an application that already has usersthe password stays, so the phishing surface stays
Federated loginconsumer products that already federatethe identity provider becomes your recovery story
Support deskenterprises with an identity-proofing process alreadyexpensive, and now the desk is the attack surface
Spring Security ships the first one, and it is one line of DSL:
.oneTimeTokenLogin((ott) -> ott.tokenGenerationSuccessHandler(handler))
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:
.requestMatchers("/passkey-only").hasAuthority("FACTOR_WEBAUTHN")
The same user, three sessions, one endpoint:
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.
private WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations(
        PublicKeyCredentialUserEntityRepository userEntities, UserCredentialRepository userCredentials) {
    Optional<WebAuthnRelyingPartyOperations> webauthnOperationsBean = getBeanOrNull(
            WebAuthnRelyingPartyOperations.class);
    String rpName = (this.rpName != null) ? this.rpName : this.rpId;
    return webauthnOperationsBean
        .orElseGet(() -> new Webauthn4JRelyingPartyOperations(userEntities, userCredentials,
                PublicKeyCredentialRpEntity.builder().id(this.rpId).name(rpName).build(), this.allowedOrigins));
}
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:
AuthenticatorSelectionCriteria.builder()
    .userVerification(UserVerificationRequirement.PREFERRED)
    .residentKey(ResidentKeyRequirement.REQUIRED)
    .build();
and both ceremonies decide whether to enforce user verification 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 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:
operations.setCustomizeCreationOptions((options) -> options.authenticatorSelection(
    AuthenticatorSelectionCriteria.builder()
        .userVerification(UserVerificationRequirement.REQUIRED)
        .residentKey(ResidentKeyRequirement.REQUIRED)
        .build()));
operations.setCustomizeRequestOptions((options) ->
    options.userVerification(UserVerificationRequirement.REQUIRED));
With both set, 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.
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:
{"attestation":"direct","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"}, ...}
Then register with an authenticator that answers fmt: "none" and an all-zero AAGUID — a credential that has declined to say what it is:
$ POST /webauthn/register
HTTP 200
{"success":true}
The reason is one field:
private WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
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:
AttestationObject wa4jAttestationObject =
        cborConverter.readValue(attestationObject.getBytes(), AttestationObject.class);
com.webauthn4j.credential.CredentialRecord wa4jCredentialRecord =
        new CredentialRecordImpl(wa4jAttestationObject, null, null, transports);
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.
What is written, and what is compared registration attestationObject signCount = 0 assertion 1 signCount = 1 assertion 2 signCount = 2 assertion 3 signCount = 3 replay signCount = 1 signature_count column, written every time 1 2 3 1 but every verification rebuilds the credential record from the attestation object above storedSignCount = 0, 0, 0, 0 — the column is never read back, so 1 > 0 passes The dashed path is the one that decides the outcome. The solid paths only fill a column. Verified by running it: three logins, then a replayed counter, HTTP 200 every time.

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:
spring:
  sql:
    init:
      mode: always
      schema-locations:
        - classpath:org/springframework/security/user-entities-schema.sql
        - classpath:org/springframework/security/user-credentials-schema.sql
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:

Should you build this

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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.