signalapp/Signal-Server · error · RequestReuseException
Sign count from request less than stored sign count
Error message
Sign count from request less than stored sign count
What it means
Signal throws RequestReuseException during DeviceCheck assertion validation when the sign counter embedded in the client's assertion is not greater than the sign counter already stored server-side. Per Apple's web-credential assertion spec (step 5), the authenticator's counter must monotonically increase; a lower or equal counter indicates a replayed or cloned assertion. The server rejects it as an anti-replay measure.
Solutions
- Ensure the client persists and increments its DeviceCheck sign counter locally after every successful assertion and never restores an old counter value.
- Regenerate the DeviceCheck/assertion key on the client (fresh key starts a new counter lineage) and re-enroll it with the server.
- If the client genuinely lost its counter (backup restore), have the user delete and recreate the linked device/credential so the server resets the stored counter.
- Check that no proxy or client middleware is caching and resending the assertion request.
Example fix
// client-side (before: reusing cached assertion)
final DeviceCheckRequest cached = loadCachedAssertion();
sendAssertion(cached);
// after: always build fresh assertion; WebAuthn authenticators increment counter internally
final AssertionResult result = platformProvider.getAssertion(challenge);
sendAssertion(result);
// server treats counter regression as replay
if (assertion.getSignCount() <= storedCounter) {
throw new RequestReuseException("Sign count from request less than stored sign count");
} Defensive patterns
Strategy: try-catch
Validate before calling
// client-side: never resend a previously used assertion
if (sentAssertions.contains(assertionHash)) { throw new IllegalStateException("assertion already used"); } Type guard
function hasFreshCounter(newCount, storedCount) { return typeof newCount === 'number' && typeof storedCount === 'number' && newCount > storedCount; } Try / catch
try {
api.validateAssertion(assertion);
} catch (RequestReuseException e) {
regenerateKeyAndReEnroll(); // counter lineage is broken; start fresh
} Prevention
- Persist the sign counter durably on the client and never restore stale values from backups
- Never reuse or cache assertion payloads across requests
- Regenerate assertion keys after backup restore
When it happens
Trigger: Client submits a DeviceCheck assertion whose signCount <= the stored counter in apple_device_checks for the (account, keyId). Happens when the same assertion payload is replayed, when the client restored an older backup with a stale counter, or when multiple devices share the same assertion key.
Common situations: Replay attacks or automated request-forgery attempts; a user restoring a device from backup so the device's counter regresses; a bug in client-side counter persistence causing stale counters to be sent.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- Provided challenge did not match stored challenge
- receipt serial is already redeemed
- Only primary devices may register attestations
AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09).
Data as JSON: /api/errors/0d07dcecd785718f.
Report an issue: GitHub.
Appendix: source
Thrown at service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java:211
}
if (!MessageDigest.isEqual(
storedChallenge.getBytes(StandardCharsets.UTF_8),
challenge.getBytes(StandardCharsets.UTF_8))) {
throw new DeviceCheckVerificationFailedException("Provided challenge did not match stored challenge");
}
final DCAppleDevice appleDevice = appleDeviceChecks.lookup(account, keyId)
.orElseThrow(DeviceCheckKeyIdNotFoundException::new);
final DCAssertionRequest dcAssertionRequest = new DCAssertionRequest(keyId, assertion, sha256(request));
final DCAssertionParameters dcAssertionParameters =
new DCAssertionParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(request)), appleDevice);
try {
deviceCheckManager.validate(dcAssertionRequest, dcAssertionParameters);
} catch (MaliciousCounterValueException e) {
// We will only accept assertions that have a sign count greater than the last assertion we saw. Step 5 here:
// https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-assertion
throw new RequestReuseException("Sign count from request less than stored sign count");
} catch (VerificationException e) {
logger.info("Failed to validate DeviceCheck assert", e);
throw new DeviceCheckVerificationFailedException(e);
}
// Store the updated sign count, so we can check the next assertion (step 6)
if (!appleDeviceChecks.updateCounter(account, keyId, appleDevice.getCounter())) {
throw new RequestReuseException("Sign count from request less than stored sign count");
}
removeChallenge(redisChallengeKey);
}
/**
* Create a challenge that can be used in an attestation or assertion
*
* @param challengeType The type of the challenge
* @param account The account that will use the challenge
* @return The challenge to be included as part of an attestation or assertionView on GitHub (pinned to 100ab61c82)