{"record":{"id":"550e8d407b5e1d4d","repo":"signalapp/Signal-Server","slug":"recovery-password-required-for-for-storage-when-re","errorCode":null,"errorMessage":"Recovery password required for for storage when recovering an account by identifier","messagePattern":"Recovery password required for for storage when recovering an account by identifier","errorType":"http","errorClass":"BadRequestException","httpStatus":400,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java","lineNumber":408,"sourceCode":"    }\n  }\n\n  private AccountCreationResponse recoverAccount(final UUID accountIdentifier,\n      final String password,\n      final RegistrationRequest registrationRequest,\n      final String userAgent,\n      final String signalAgent) throws RegistrationLockFailureException, RateLimitExceededException {\n\n    if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) {\n      throw new BadRequestException(\"login purchases are not enabled\");\n    }\n\n    if (ArrayUtils.isEmpty(registrationRequest.recoveryPassword())) {\n      throw new BadRequestException(\"Recovery password required for authentication when recovering an account by identifier\");\n    }\n\n    if (registrationRequest.accountAttributes().recoveryPassword().isEmpty()) {\n      throw new BadRequestException(\"Recovery password required for for storage when recovering an account by identifier\");\n    }\n\n    if (registrationRequest.pniIdentityKey() == null) {\n      throw new BadRequestException(\"Must specify a PNI-associated identity key when recovering an account by identifier\");\n    }\n\n    final Account existingAccount = accounts.getByAccountIdentifier(accountIdentifier)\n            .orElseThrow(ForbiddenException::new);\n\n    final boolean passwordVerified = existingAccount.getAccountRecoveryPassword()\n        .map(saltedRecoveryPasswordHash -> PhoneNumberRecoveryPasswordsManager.verify(saltedRecoveryPasswordHash, registrationRequest.recoveryPassword()))\n        .orElse(false);\n\n    if (!passwordVerified) {\n      throw new ForbiddenException();\n    }\n\n    checkTotp(existingAccount, registrationRequest.totp());","sourceCodeStart":390,"sourceCodeEnd":426,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java#L390-L426","documentation":"recoverAccount separately requires that the request's accountAttributes carry a recoveryPassword for storage on the account. When accountAttributes().recoveryPassword() is empty, the controller throws a BadRequestException (HTTP 400). Note the message contains a typo ('required for for storage'); it is distinct from the authentication password check at line 404.","triggerScenarios":"Submitting a recovery-by-identifier request where the top-level recoveryPassword is present but registrationRequest.accountAttributes().recoveryPassword() is empty — i.e. the attributes payload omits the storage recovery password.","commonSituations":"Client builds AccountAttributes without the recovery password (older schema or forgotten field); the two recovery-password fields are confused and only one is populated; request-mapping code drops the attribute during JSON deserialization.","solutions":["Set accountAttributes.recoveryPassword (the storage copy) in addition to the top-level authentication recoveryPassword — both are required.","Verify JSON serialization includes the recoveryPassword field inside accountAttributes (not just at the request root).","Align the client's AccountAttributes builder with the current server API version so the field is populated.","Update integration tests to assert both recovery-password fields are non-empty before calling the recovery endpoint."],"exampleFix":"// before\nAccountAttributes attrs = new AccountAttributes(capabilities, true, null, name, pni, apnId);\n// after\nAccountAttributes attrs = new AccountAttributes(capabilities, true, storageRecoveryPassword, name, pni, apnId);","handlingStrategy":"validation","validationCode":"if (request.accountAttributes().recoveryPassword().isEmpty()) {\n  throw new IllegalArgumentException(\"accountAttributes.recoveryPassword (storage) is required\");\n}","typeGuard":"boolean hasStorageRecoveryPassword(RegistrationRequest r) {\n  return !r.accountAttributes().recoveryPassword().isEmpty();\n}","tryCatchPattern":"try {\n  recoverAccount(request);\n} catch (BadRequestException e) {\n  if (e.getMessage().startsWith(\"Recovery password required for\")) {\n    rebuildAttributesWithStorageRecoveryPassword();\n  } else throw e;\n}","preventionTips":["Remember both recovery-password fields are required: request-level (auth) and accountAttributes-level (storage).","Verify JSON serialization emits recoveryPassword inside accountAttributes.","Add client-side tests covering both fields."],"tags":["account-recovery","missing-field","bad-request","account-attributes"],"backgroundTag":"empty-required-field","analyzedSha":"100ab61c82627582c867d19e1c0561ba2781e927","analyzedAt":"2026-09-09T13:29:47.883Z","contentChangedAt":"2026-09-09T13:29:47.883Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}