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
- Register a credential from an authenticator that emits an incrementing signature counter
(e.g. registration signCount = 0).
- Authenticate once with signature counter = 10 → succeeds;
signature_count is persisted
as 10.
- 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.
Summary
Webauthn4JRelyingPartyOperations.authenticate(...)no longer feeds the persistedCredentialRecord.getSignatureCount()into webauthn4j when verifying an assertion. As aresult 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
spring-security-webauthn7.0.x (verified on 7.0.5)spring-security-web6.5.x (verified on 6.5.10), where the WebAuthnsupport lived before it was extracted into the
spring-security-webauthnmodule.Impact
cloned-authenticator detection defined by the WebAuthn spec
(https://www.w3.org/TR/webauthn-3/#sign-counter) silently does not work.
MaliciousCounterValueHandlerto reject aregressed 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
0and many RPs intentionally ignore counters. The defect matters only forhardware 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:In 7.0.x the code was migrated to the newer webauthn4j
CredentialRecord/WebAuthnManager.verify(...)API, but the counter injection was dropped:CredentialRecordImpl(AttestationObject, ...)delegates toCoreCredentialRecordImpl(AttestationObject), which seeds the counter fromattestationObject.getAuthenticatorData().getSignCount()— i.e. the value captured atregistration (usually
0), not the persisted running value:Because the persisted
signatureCount(whichauthenticate(...)itself keeps updating viauserCredentials.save(...)) is never applied to theCredentialRecordImplbeforeverify(...), webauthn4j always compares the presented counter against the registrationcounter. The comparison
presentedCount <= storedCounttherefore effectively becomespresentedCount <= registrationCount, which never holds for a normally increasing counter.Steps to reproduce
(e.g. registration signCount = 0).
signature_countis persistedas 10.
would produce).
Expected: step 3 is rejected — webauthn4j invokes the configured
MaliciousCounterValueHandler(by default throwingMaliciousCounterValueException),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 <= 0is false.Suggested fix
Apply the persisted counter to the record before verification, mirroring the 6.5.x
behaviour.
CredentialRecordImplinherits a publicsetCounter(long)fromcom.webauthn4j.authenticator.CoreAuthenticatorImpl, so a minimal change is: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.RELEASESecurity 7.0.x; the same application on Spring Boot 3.5 / Spring Authorization Server 1.5.x
(Spring Security 6.5.x) behaves correctly.