Interpreting signCount Anomalies and Clone Detection
The signCount is WebAuthn’s built-in clone detector: a monotonic counter the authenticator increments on each assertion, so a value that fails to increase suggests two copies of the same private key are in use. But the signal is noisier than it looks — many passkeys report a permanent zero, synced credentials can regress benignly, and a naive “reject on non-increase” rule locks out legitimate users. This page is the interpretation guide: what each pattern means and whether to reject, flag, or ignore it. It sits under the server-side debugging cluster.
Pattern Reference
| Observed pattern | Likely meaning | Recommended action |
|---|---|---|
new > stored, both non-zero |
Normal | Accept; update stored counter |
new === 0, stored === 0 |
Counter-less authenticator (common for synced passkeys) | Accept; do not use counter as a signal |
new <= stored, device-bound key |
Possible clone | Reject + revoke + alert |
new <= stored, synced passkey |
Sync race or multi-device | Flag + step-up, not hard reject |
new === stored, non-zero |
Replay or duplicate submit | Reject; check idempotency |
The counter lives at bytes 33–36 of authenticatorData as a big-endian uint32 (WebAuthn L2 §6.1.1), and §7.2 step 21 defines the check: if both stored and received counters are non-zero, the received value must be greater.
Root Cause Analysis
1. Zero-counter authenticators. Many platform authenticators and synced passkeys always emit 0. The spec explicitly allows this and says to skip the counter check when both values are zero. Rejecting zero counters locks out a large share of real users.
2. Sync races on multi-device passkeys. A credential synced across devices may present a lower counter from a device that has not yet received the latest sync, producing a benign regression.
3. Genuine cloning of a device-bound key. For a hardware authenticator that does maintain a counter, a regression is a strong indicator that the private key was extracted and is being used elsewhere.
4. Duplicate submission / replay. An equal, non-zero counter often means the same assertion was submitted twice — an idempotency issue, not necessarily an attack.
Step-by-Step Resolution
Step 1 — Branch the check on counter and credential type
function evaluateSignCount(newC: number, storedC: number, deviceType: 'singleDevice' | 'multiDevice') {
if (newC === 0 && storedC === 0) return { ok: true, signal: 'no-counter' };
if (newC > storedC) return { ok: true, signal: 'normal' };
// non-increase:
if (deviceType === 'singleDevice') return { ok: false, signal: 'clone-suspected' }; // device-bound → reject
return { ok: true, signal: 'sync-race-flag' }; // synced → flag, allow
}
Step 2 — Persist the higher counter atomically
await db.$executeRaw`
UPDATE credentials SET sign_count = ${newC}
WHERE id = ${credentialId} AND sign_count < ${newC}`; // never move the counter backwards
Step 3 — Act on the signal
For clone-suspected, reject the assertion and trigger revoking the credential. For sync-race-flag, allow the sign-in but require step-up authentication for sensitive actions and log for review.
Step 4 — Record the credentialDeviceType at registration
Store singleDevice/multiDevice (from verifyRegistrationResponse) so the branch in Step 1 has the data it needs.
Verification and Testing
expect(evaluateSignCount(5, 4, 'singleDevice')).toMatchObject({ ok: true, signal: 'normal' });
expect(evaluateSignCount(4, 5, 'singleDevice')).toMatchObject({ ok: false, signal: 'clone-suspected' });
expect(evaluateSignCount(4, 5, 'multiDevice')).toMatchObject({ ok: true, signal: 'sync-race-flag' });
expect(evaluateSignCount(0, 0, 'multiDevice')).toMatchObject({ ok: true, signal: 'no-counter' });
Assert the stored counter never decreases under concurrent assertions (the sign_count < newC guard). Emit a webauthn.signcount.<signal> metric and confirm a clone-suspected event revokes the credential and alerts.
Pitfalls
1. Rejecting zero counters. Skip the check when both are zero; it is spec-compliant and common.
2. Hard-rejecting synced-passkey regressions. Flag and step-up instead of locking out multi-device users.
3. Moving the counter backward. Guard the update with sign_count < newC so races never regress the stored value.
Related
- Debugging and Observability for WebAuthn Servers — the parent pipeline and the signCount verification step
- Implementing Authentication Verification Logic — where the counter check lives in the full assertion pipeline
- Revoking a Lost Passkey and Forcing Re-Authentication — the action a confirmed clone triggers