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
- Fetch a fresh challenge from the challenge endpoint immediately before generating each DeviceCheck assertion and use exactly those bytes.
- Never reuse or cache challenges client-side across requests; each assertion needs a new server-issued challenge.
- 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.
- 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
- Fetch a new challenge immediately before every assertion — never cache or reuse
- Encode challenges as raw UTF-8 bytes without transformation
- Size the server challenge cache TTL to comfortably exceed worst-case client latency
- Treat repeated mismatches from a client as a potential replay attack signal
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
- Sign count from request less than stored sign count
- 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/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)