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

  1. Obtain a fresh recovery password from the registration service and use it immediately in the same flow
  2. Verify the recovery password is transmitted intact (correct base64, no truncation/whitespace)
  3. 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

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


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)