signalapp/Signal-Server · error · ForbiddenException
recovery password could not be verified
Error message
recovery password could not be verified
What it means
When registering with a recovery password instead of a verified session, the server validates the recovery password against the registration service. If validation fails (RecoveryPasswordVerificationFailedException), the controller returns 403 Forbidden 'recovery password could not be verified'. This means the presented recovery password doesn't match or wasn't issued for this registration attempt.
Solutions
- Obtain a fresh recovery password from the registration service and use it immediately in the same flow
- Verify the recovery password is transmitted intact (correct base64, no truncation/whitespace)
- If the password was already consumed, restart the registration flow to get a new one
Example fix
// before recoveryPassword = oldStoredPassword; // from a prior, already-used attempt // after recoveryPassword = fetchFreshRecoveryPassword(number); // newly issued for this attempt
Defensive patterns
Strategy: try-catch
Validate before calling
if (recoveryPassword == null || recoveryPassword.isBlank()) { throw new IllegalArgumentException("recoveryPassword required for this registration path"); } Try / catch
try { /* registration */ } catch (ForbiddenException e) { if ("recovery password could not be verified".equals(e.getMessage())) { requestNewRecoveryPasswordAndRetry(); } } Prevention
- Use recovery passwords immediately after issuance — they are single-use/expiring
- Guard against encoding corruption (base64, whitespace) before sending
- On 403, always fetch a fresh password rather than retrying the old one
When it happens
Trigger: POST /v1/registration passing a recoveryPassword whose verification via phoneVerificationTokenManager.verify throws RecoveryPasswordVerificationFailedException — wrong, expired, already-used, or truncated recovery password bytes.
Common situations: Client supplying the recovery password from a previous registration attempt; base64 encoding/copy errors corrupting the password; password expired by the time registration is attempted.
Related errors
- recovery password could not be verified
- registration session is unverified
- 403 Forbidden
- ?…
- number does not match session
AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09).
Data as JSON: /api/errors/5f1bbee75ad49008.
Report an issue: GitHub.
Appendix: source
Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java:245
}
final PhoneVerificationRequest.VerificationType verificationType;
try {
verificationType = phoneVerificationTokenManager.verify(
number,
requestContext.getHeaderString(HttpHeaders.USER_AGENT),
requestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE),
(String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME),
StringUtils.isNotBlank(registrationRequest.sessionId()) ? registrationRequest.decodeSessionId() : null,
registrationRequest.recoveryPassword());
} catch (final UnverifiedRegistrationSessionException e) {
throw new NotAuthorizedException("registration session is unverified");
} catch (final InvalidRegistrationSessionException e) {
throw new BadRequestException(e.getMessage());
} catch (final IOException e) {
throw new ServiceUnavailableException(e.getMessage());
} catch (final RecoveryPasswordVerificationFailedException e) {
throw new ForbiddenException("recovery password could not be verified");
}
rateLimiters.getRegistrationLimiter().validate(number);
// There can be at most one existing account for a set of numbers in the same equivalence class, so it's sufficient
// to find the first one.
final Optional<Account> existingAccount = Util.getAlternateForms(number)
.stream()
.map(accounts::getByE164)
.filter(Optional::isPresent)
.map(Optional::get)
.findFirst();
existingAccount.ifPresent(account -> {
final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());
final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());
REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays());
});View on GitHub (pinned to 100ab61c82)