[← 09 — Persistence](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md) # 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`](../../passkeys/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`](../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: ```java 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: ```java 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: ```java 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`](../../passkeys/src/test/java/com/ankurm/passkeys/PasskeyContractTests.java) 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: ```java @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](09-persistence.md) · [index](README.md) · next: [11 — Should you build this](11-should-you.md)