[← 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 makes it optional.** WebAuthn Level 3 says a relying party MAY treat a non-increasing counter as a signal of cloning, and explicitly leaves the response to the relying party. Spring Security is not violating anything. **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)