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

139 lines
6.4 KiB
Markdown

[← 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());
```
&mdash; `CoreCredentialRecordImpl`, WebAuthn4J 0.31.9
The attestation object is the one captured at **registration**. Its counter is frozen at
whatever the authenticator reported then &mdash; almost always 0. So the comparison is always
&ldquo;is the presented counter greater than 0?&rdquo;, 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 &mdash; 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 &ldquo;a signal, but not proof,
that the authenticator may be cloned&rdquo;, since it might equally be a malfunctioning
authenticator or assertions processed out of order, and relying parties are told to
&ldquo;evaluate their own operational characteristics and incorporate this information into
their risk scoring&rdquo;. 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 &mdash;
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 &mdash; 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.
[&larr; 09 &mdash; Persistence](09-persistence.md) &middot; [index](README.md) &middot; next: [11 &mdash; Should you build this](11-should-you.md)