Skip to content

WebAuthn signature counter is no longer used for clone detection (regression in 7.0) #19473

Description

@gavinrwalters

Summary

Webauthn4JRelyingPartyOperations.authenticate(...) no longer feeds the persisted
CredentialRecord.getSignatureCount() into webauthn4j when verifying an assertion. As a
result the authenticator sign-count is never compared against the stored value, so
sign-count regression / cloned-authenticator detection is effectively disabled.

The running counter is still updated and persisted after each authentication, but it is
never read back into verification — it has become write-only.

This is a regression: Spring Security 6.5.x injected the persisted counter correctly.

Affected versions

  • Broken: spring-security-webauthn 7.0.x (verified on 7.0.5)
  • Last working: spring-security-web 6.5.x (verified on 6.5.10), where the WebAuthn
    support lived before it was extracted into the spring-security-webauthn module.

Impact

  • For authenticators that implement a real, monotonically increasing signature counter,
    cloned-authenticator detection defined by the WebAuthn spec
    (https://www.w3.org/TR/webauthn-3/#sign-counter) silently does not work.
  • Any application that relies on webauthn4j's MaliciousCounterValueHandler to reject a
    regressed counter will never see it fire, because the value webauthn4j compares against
    is always the registration-time counter (typically 0).

Note: severity is limited in practice because synced passkeys (Apple/Google, etc.) report
a sign count of 0 and many RPs intentionally ignore counters. The defect matters only for
hardware authenticators with a genuine counter, but the current behaviour contradicts both
the documented webauthn4j usage and the previous Spring Security behaviour.

Root cause

In 6.5.x the verification authenticator was built with the persisted counter and validated
via the (now-deprecated) webauthn4j Authenticator / WebAuthnManager.validate(...) API:

// spring-security-web 6.5.10 — Webauthn4JRelyingPartyOperations.authenticate(...)
CredentialRecord stored = this.userCredentials.findByCredentialId(rawId);
AttestationObject attestationObject =
    cborConverter.readValue(stored.getAttestationObject().getBytes(), AttestationObject.class);
AttestedCredentialData acd = new AttestedCredentialData(aaguid, rawId, coseKey);

AuthenticatorImpl authenticator = new AuthenticatorImpl(
        acd,
        attestationObject.getAttestationStatement(),
        stored.getSignatureCount());              // <-- persisted running counter injected

AuthenticationParameters params =
    new AuthenticationParameters(serverProperty, authenticator, userVerificationRequired);
AuthenticationData data = this.webAuthnManager.validate(authRequest, params);

In 7.0.x the code was migrated to the newer webauthn4j CredentialRecord /
WebAuthnManager.verify(...) API, but the counter injection was dropped:

// spring-security-webauthn 7.0.5 — Webauthn4JRelyingPartyOperations.authenticate(...)
CredentialRecord stored = this.userCredentials.findByCredentialId(rawId);
AttestationObject attestationObject =
    cborConverter.readValue(stored.getAttestationObject().getBytes(), AttestationObject.class);

CredentialRecordImpl credentialRecord =
    new CredentialRecordImpl(attestationObject, null, null, transports);
// getSignatureCount() is never read; the counter is seeded from the attestation object.

AuthenticationParameters params =
    new AuthenticationParameters(serverProperty, credentialRecord, allowCredentialIds,
        userVerificationRequired);
AuthenticationData data = this.webAuthnManager.verify(authRequest, params);

CredentialRecordImpl(AttestationObject, ...) delegates to
CoreCredentialRecordImpl(AttestationObject), which seeds the counter from
attestationObject.getAuthenticatorData().getSignCount() — i.e. the value captured at
registration (usually 0), not the persisted running value:

// com.webauthn4j.credential.CoreCredentialRecordImpl(AttestationObject)
super(
    attestationObject.getAuthenticatorData().getAttestedCredentialData(),
    attestationObject.getAttestationStatement(),
    attestationObject.getAuthenticatorData().getSignCount(),   // registration signCount
    attestationObject.getAuthenticatorData().getExtensions());

Because the persisted signatureCount (which authenticate(...) itself keeps updating via
userCredentials.save(...)) is never applied to the CredentialRecordImpl before
verify(...), webauthn4j always compares the presented counter against the registration
counter. The comparison presentedCount <= storedCount therefore effectively becomes
presentedCount <= registrationCount, which never holds for a normally increasing counter.

Steps to reproduce

  1. Register a credential from an authenticator that emits an incrementing signature counter
    (e.g. registration signCount = 0).
  2. Authenticate once with signature counter = 10 → succeeds; signature_count is persisted
    as 10.
  3. Authenticate again with signature counter = 1 (a regression, as a cloned authenticator
    would produce).

Expected: step 3 is rejected — webauthn4j invokes the configured
MaliciousCounterValueHandler (by default throwing MaliciousCounterValueException),
because 1 <= 10.

Actual: step 3 succeeds. The handler is never invoked because the counter webauthn4j
compares against is the registration counter (0), so 1 <= 0 is false.

Suggested fix

Apply the persisted counter to the record before verification, mirroring the 6.5.x
behaviour. CredentialRecordImpl inherits a public setCounter(long) from
com.webauthn4j.authenticator.CoreAuthenticatorImpl, so a minimal change is:

CredentialRecordImpl credentialRecord =
    new CredentialRecordImpl(attestationObject, null, null, transports);
credentialRecord.setCounter(stored.getSignatureCount());   // restore persisted counter

Environment

  • spring-security-webauthn: 7.0.5 (affected)
  • spring-security-web: 6.5.10 (last known-good, WebAuthn support pre-extraction)
  • webauthn4j-core: 0.31.3.RELEASE
  • Migration context: Spring Boot 4 / Spring Authorization Server 2.x pulls in Spring
    Security 7.0.x; the same application on Spring Boot 3.5 / Spring Authorization Server 1.5.x
    (Spring Security 6.5.x) behaves correctly.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions