1
0
Files
spring-auth-demo/docs/passkeys/10-signature-counter.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

6.4 KiB

← 09 — Persistence · index · next: 11 — Should you build this

The signature counter is stored on every login and compared against on none

WebAuthn's signature counter exists for one purpose: detecting a cloned authenticator. A hardware key increments a monotonic counter each 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 for verification.

Watching it happen

scripts/counter.sh registers one credential, then authenticates three times with counters 1, 2 and 3, checking what the relying party stored after each. Then it presents a counter of 1 again. From docs/output/pk-counter.txt:

--- 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 hope. Then a counter of 1 is accepted.

Why

WebAuthn4J does implement the check. AuthenticationDataVerifier follows the specification line by line:

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 builds the WebAuthn4J credential record like this:

AttestationObject wa4jAttestationObject = cborConverter.readValue(attestationObject.getBytes(), AttestationObject.class);
com.webauthn4j.credential.CredentialRecord wa4jCredentialRecord =
        new CredentialRecordImpl(wa4jAttestationObject, null, null, transports);

Webauthn4JRelyingPartyOperations.authenticate, Spring Security 7.1.1

and that constructor derives the counter from the attestation object:

public CoreCredentialRecordImpl(@NotNull AttestationObject attestationObject) {
    super(attestationObject.getAuthenticatorData().getAttestedCredentialData(),
            attestationObject.getAttestationStatement(),
            attestationObject.getAuthenticatorData().getSignCount(),   // <-- the counter
            attestationObject.getAuthenticatorData().getExtensions());

CoreCredentialRecordImpl, WebAuthn4J 0.31.9

The attestation object is the one captured at registration. Its counter is frozen at whatever the authenticator reported then — almost always 0. So the comparison is always “is the presented counter greater than 0?”, the persisted signature_count column is never consulted, and the only assertion that would ever trip the check is one presenting a counter of exactly 0 against a registration counter of 0. Which is precisely what every synced passkey does, on every login, forever — and those are not rejected either, because of the presentedSignCount > 0 || storedSignCount > 0 guard.

The contract test signatureCounterIsStoredButNotVerified pins this at the operations level, with no HTTP involved, and will start failing the day it changes.

How much does this matter?

Less than the paragraphs above suggest, and it is worth being precise about why.

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. What it does not do is prescribe the response: a counter that fails to increase is “a signal, but not proof, that the authenticator may be cloned”, since it might equally be a malfunctioning authenticator or assertions processed out of order, and relying parties are told 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. A credential synced through iCloud Keychain, Google Password Manager or 1Password exists on several devices by design; a monotonic counter is meaningless across them, so those authenticators report 0 permanently. For a consumer application where most credentials are syncable, counter checking would detect nothing and would risk false positives.

It is still worth knowing. If you are enrolling FIDO2 hardware keys in an enterprise setting, the counter is real, it increments, and the check is a genuine clone detector — and you do not have it. The signature_count column filling up correctly makes it look like you do, which is the actual hazard here: not a hole, but a control you might believe you have.

If you need it

Nothing in the API blocks you. Wrap the operations bean:

@Bean
WebAuthnRelyingPartyOperations relyingPartyOperations(UserCredentialRepository credentials, ...) {
    Webauthn4JRelyingPartyOperations delegate = new Webauthn4JRelyingPartyOperations(...);
    return new CounterCheckingRelyingPartyOperations(delegate, credentials);
}

where the wrapper reads credentials.findByCredentialId(id).getSignatureCount() before delegating, remembers it, and after authenticate returns compares it against the value the delegate has just written — rejecting 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.

← 09 — Persistence · index · next: 11 — Should you build this