What WebAuthn signCount tells you: the counter check and clone signals

Per WebAuthn Level 3: what signCount counts, when it stays at zero, the exact comparison an RP runs, why a lower or equal value is a signal rather than proof, and how the BE and BS flags relate.

webauthnpasskeyauthenticationsecurity

A server that verifies WebAuthn logins (the Relying Party, or RP) receives a 4-byte value called signCount inside the authenticator data returned by the authenticator. It is a signature counter meant to help the RP notice that an authenticator has been cloned. Working with it raises a few questions. Is something wrong if the value is always 0? Should a login be blocked when the value is lower than or equal to the stored one? Should a synced credential be treated differently?

This post is based on the W3C Recommendation Web Authentication Level 3 (dated 25 August 2026), checked on 2026-10-15. FIDO Alliance specifications such as CTAP, and the counter behavior of particular platforms or vendors, were not used as sources, so this post does not say which authenticator sends which value. Below, the specification’s requirements (SHOULD, MUST and so on) are kept apart from the parts the specification leaves to RP policy.

Where it sits in authenticator data

Per §6.1, authenticator data is a byte array of 37 bytes or more, and its start is laid out in this fixed order.

Field Length Contents
rpIdHash 32 bytes SHA-256 hash of the RP ID
flags 1 byte UP (bit 0), UV (bit 2), BE (bit 3), BS (bit 4), AT (bit 6), ED (bit 7)
signCount 4 bytes Signature counter, 32-bit unsigned big-endian integer

Counting from 0, signCount occupies bytes 33 through 36. As an unsigned 32-bit value, its maximum is 2^32 − 1. A storage column typed as a signed 32-bit integer cannot hold values of 2^31 or more. That last point is not a sentence in the specification; it is an implementation caution that follows from the field definition.

How the counter moves at registration and authentication

§6.1.1 says authenticators SHOULD implement a signature counter. It is not a MUST, so authenticators without one are within the specification. The counter may be kept per credential or once for the whole authenticator.

At registration (authenticatorMakeCredential, §6.3.2), the first counter value of a new credential depends on the authenticator.

  • If it supports a per-credential counter, it allocates a new counter and initializes it to zero.
  • If it supports a global counter, it uses that global counter’s actual value.
  • If it is a U2F device, the value is zero. U2F devices may support counters but do not return one when making a credential.
  • If it does not support a counter, the value is zero and stays zero.

Each time authentication (authenticatorGetAssertion, §6.3.3) succeeds, the authenticator increments the counter “by some positive value”. Nothing says it goes up by 1, so gaps between the values an RP receives are not unusual in themselves. An authenticator without a counter keeps sending 0.

On the RP side, the signCount from the authenticator data is stored in the credential record at registration (§7.1), and each later authentication compares the new value against it. As defined in §4, the credential record’s signCount is the latest counter value from any ceremony that used the credential.

The exact comparison the RP runs

The relevant step in §7.2 says:

  1. If either authData.signCount or the stored credentialRecord.signCount is non-zero, run the comparison below. If both are zero, this comparison step does not run.
  2. If the new value is greater than the stored value, the signature counter is valid.
  3. If the new value is less than or equal to the stored value, this is a signal, but not proof, that the authenticator may be cloned.

Some cases as a table:

Stored New Result
0 0 Not compared (the pattern from an authenticator that does not use a counter)
0 7 Valid
41 42 Valid
41 50 Valid (the increment size is not fixed)
41 41 Possible-clone signal
41 12 Possible-clone signal
41 0 Possible-clone signal (compared, because the stored value is non-zero)

The last row is easy to miss. Code that looks only at the new value and skips zero as “no counter support” will miss a credential that used to send non-zero values and suddenly sends 0. The specification’s condition is to compare whenever either value is non-zero.

A lower or equal value is a signal, not proof

§7.2 gives three example explanations for a value lower than or equal to the stored one.

  • Two or more copies of the credential private key exist and are being used in parallel.
  • An authenticator is malfunctioning.
  • A race condition exists where the RP is processing assertion responses in an order other than the order they were generated at the authenticator.

§6.1.1 adds that detecting a counter mismatch does not indicate whether the current operation was performed by the cloned authenticator or the original one. Even if a clone really exists, there is no basis for concluding that the party who just logged in is the attacker.

So the specification leaves the response to the RP. Per §7.2, whether the RP updates the stored signCount in this case, and whether it fails the authentication ceremony, is Relying Party-specific. RPs should evaluate their own operational characteristics and incorporate this information into their risk scoring. §6.1.1 likewise asks RPs to handle the situation according to their risk tolerance and to operational factors that might give an acceptable reason for non-increasing values. §6.1.1 also notes that clone detection matters more for authenticators with limited protection measures.

That the specification does not say “always reject on mismatch” matters when writing policy. Malfunction or out-of-order processing can produce the same result, so a policy that automatically locks an account on a counter mismatch alone can lock out legitimate users. A graduated response is worth considering: record the event, look at it together with other risk signals, and ask for additional verification when needed. The recommendation in this paragraph is a judgment drawn from the text above, not a requirement of the specification.

Pseudocode for the comparison step

The following is policy-oriented pseudocode showing only the counter comparison. It is not a complete WebAuthn verification procedure. None of the other §7.2 steps, including signature verification, challenge, origin and RP ID hash checks, and UP and UV flag checks, can be skipped, and the code below only makes sense on the premise that those steps have already passed.

# Policy-oriented pseudocode. Run only after every other §7.2 verification step has passed.
stored = credentialRecord.signCount
new    = authData.signCount              # 32-bit unsigned big-endian integer

if new == 0 and stored == 0:
    result = "not_compared"              # the spec's comparison step does not run
elif new > stored:
    result = "valid"
else:
    result = "signal"                    # a possible-clone signal, not proof

update = true
if result == "signal":
    log_risk_signal(credentialId, stored, new)
    action = rp_policy(credentialId, risk_context)
    # e.g. allow and record, require step-up auth, queue for review, fail the ceremony
    if action == "fail":
        return fail_ceremony()
    update = action.updates_count        # whether to update is also RP policy

if update:
    credentialRecord.signCount = new

Per-credential and global counters

§6.1.1 says authenticators SHOULD implement per-credential counters, because a counter value shared across RPs could be used as a correlation handle for the user. A global counter is allowed (MAY) but is described as less privacy-friendly. The same section asks authenticators (SHOULD) to make sure the counter does not accidentally decrease, for example due to hardware failures.

What matters to the RP is that, in either case, the value it receives is judged only on “is it greater than the previous value”. Increment size and continuity are not part of the comparison.

What the BE and BS flags do and do not say

Level 3 has two flags related to backup. They are easy to mix up with the counter when handling synced credentials, so they are covered here as well.

  • BE (Backup Eligibility, bit 3). The authenticator that creates the credential decides at creation time whether it may be backed up. Per §6.1.3, this value is set during authenticatorMakeCredential and MUST NOT change. A credential with BE set to 1 is called a multi-device credential; with 0, a single-device credential.
  • BS (Backup State, bit 4). Indicates whether the credential is currently backed up, and can change over time. BE 0 with BS 1 is not an allowed combination, and §7.2 has the RP check for it.

The §4 terminology explains that a credential eligible for backup may come to be present on an authenticator other than the one that generated it, through mechanisms including peer-to-peer sync, cloud sync, local network sync and manual import/export. This is backup as defined by the specification, so a credential existing on several devices is not, by itself, grounds to treat it as a cloning attack.

There are also things these flags do not say.

  • BE and BS do not tell you whether a credential’s counter is zero or how it moves. The sections read for this post (§6.1.1, §6.1.3, §7.2) contain no rule linking these flags to signCount. Nor could this post find a basis in the specification for asserting that “synced credentials always have a zero counter”.
  • §6.1.3 describes a BS change from 1 to 0 as possibly the result of the user disabling the backup service or of problems with the backup service, and recommends guiding the user to validate their other authentication factors. It does not describe it as a sign of cloning.

BE is a different matter. §7.2 has the RP, when it uses backup state in business logic or policy, check that this BE value matches the stored backupEligible. A value that must not change has changed, so this check is a verification step separate from the counter comparison.

What to check

  • Confirm the comparison condition is “either value is non-zero”. Skipping on the new value alone misses a non-zero counter that has dropped back to 0.
  • Look for code that assumes an increment of 1.
  • Confirm that the signCount storage column can hold every unsigned 32-bit value.
  • Write down as policy what to do when the value is lower or equal, and whether to update the stored value. The specification leaves this decision to the RP.
  • If several servers process assertions for the same credential concurrently, decide how to tell apart mismatches caused by out-of-order processing.
  • Confirm the counter comparison code does not stand in for signature, challenge, origin, RP ID or UP/UV verification.

References

Checked on 2026-10-15.

Back to the blog