signalapp/Signal-Server · error · DeviceCheckVerificationFailedException

Provided challenge did not match stored challenge

Error message

Provided challenge did not match stored challenge

What it means

AppleDeviceCheckManager.validateAssert throws DeviceCheckVerificationFailedException("Provided challenge did not match stored challenge") when the challenge embedded in the client's DeviceCheck assertion does not equal (constant-time comparison) the challenge previously issued and stored in the challenge cache. The assertion is therefore stale, replayed, or fabricated.

Solutions

  1. Fetch a fresh challenge from the challenge endpoint immediately before generating each DeviceCheck assertion and use exactly those bytes.
  2. Never reuse or cache challenges client-side across requests; each assertion needs a new server-issued challenge.
  3. Ensure the client encodes the challenge as UTF-8 without alteration (no re-basing, trimming, or charset conversion) when embedding it in the assertion payload.
  4. If challenges expire quickly, reduce the delay between challenge issuance and assertion, and check cache TTL configuration server-side.

Example fix

// before
String challenge = cachedChallenge; // stale, fetched minutes ago
byte[] assertion = generateAssertion(account, keyId, challenge);
// after
String challenge = fetchNewChallenge(account); // fresh per assertion
byte[] assertion = generateAssertion(account, keyId, challenge.getBytes(StandardCharsets.UTF_8));
Defensive patterns

Strategy: retry

Validate before calling

// client-side: verify the challenge used is the one most recently issued for this account
if (!challenge.equals(lastIssuedChallenge) || challengeIssuedAt + TTL < now()) {
  challenge = fetchNewChallenge(account);
}

Try / catch

try {
  deviceCheckManager.validateAssert(accountnumber, request, keyId, assertion, challenge);
} catch (DeviceCheckVerificationFailedException e) {
  // challenge stale/mismatched: request a fresh challenge and retry once
} catch (ChallengeNotFoundException e) {
  // challenge expired or never issued
}

Prevention

When it happens

Trigger: Calling validateAssert with a challenge value different from the one returned by the challenge-issuing endpoint for that account, including expired/evicted challenges (storedChallenge == null raises ChallengeNotFoundException instead), reused nonces, or replayed assertions.

Common situations: Client caches a challenge and reuses it for multiple assertions instead of fetching a fresh one per assertion; challenge TTL elapsed and it was evicted from the cache; clock/client/server mismatch causing the wrong challenge bytes (e.g. charset or hashing differences); replaying a captured assertion in an attack attempt.

Related errors


AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09). Data as JSON: /api/errors/cb5b1b0d4671342f. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java:197

      final Account account,
      final byte[] keyId,
      final ChallengeType challengeType,
      final String challenge,
      final byte[] request,
      final byte[] assertion)
      throws ChallengeNotFoundException, DeviceCheckVerificationFailedException, DeviceCheckKeyIdNotFoundException, RequestReuseException {

    final String redisChallengeKey = challengeKey(challengeType, account.getAccountIdentifier());
    @Nullable final String storedChallenge = ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)
            .executeSupplier(() -> redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey)));

    if (storedChallenge == null) {
      throw new ChallengeNotFoundException();
    }
    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);
    }

View on GitHub (pinned to 100ab61c82)