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

  1. Ensure the client persists and increments its DeviceCheck sign counter locally after every successful assertion and never restores an old counter value.
  2. Regenerate the DeviceCheck/assertion key on the client (fresh key starts a new counter lineage) and re-enroll it with the server.
  3. 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.
  4. 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

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


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 assertion

View on GitHub (pinned to 100ab61c82)