signalapp/Signal-Server · error · BadRequestException
Must specify a PNI-associated identity key when recovering…
Error message
Must specify a PNI-associated identity key when recovering an account by identifier
What it means
recoverAccount requires a PNI (phone-number identity) identity key when recovering an account by identifier, since recovery re-establishes the PNI identity. If registrationRequest.pniIdentityKey() is null the controller throws a BadRequestException (HTTP 400) before loading the existing account.
Solutions
- Generate and include the PNI identity key (IdentityKey for the PNI pair) in the RegistrationRequest.
- Upgrade the client to a PNI-aware version — pre-PNI clients cannot perform identifier recovery against this server API.
- Check request JSON deserialization logs if the key was sent but arrived null (field-name mismatch, wrong nesting).
- Update API test harnesses to always set pniIdentityKey alongside aciIdentityKey.
Example fix
// before new RegistrationRequest(attrs, aciIdentityKey, null, recoveryPassword); // after new RegistrationRequest(attrs, aciIdentityKey, pniIdentityKey, recoveryPassword);
Defensive patterns
Strategy: validation
Validate before calling
if (request.pniIdentityKey() == null) {
throw new IllegalArgumentException("pniIdentityKey is required for recovery by identifier");
} Type guard
boolean hasPniIdentityKey(RegistrationRequest r) {
return r.pniIdentityKey() != null;
} Try / catch
try {
recoverAccount(request);
} catch (BadRequestException e) {
if (e.getMessage().contains("PNI-associated identity key")) {
generatePniIdentityKeyAndRetry();
} else throw e;
} Prevention
- Always generate and send both ACI and PNI identity keys in registration/recovery requests.
- Require a PNI-capable client version; pre-PNI clients cannot use this endpoint.
- Check deserialization logs when the key is sent but arrives null.
When it happens
Trigger: Submitting a recovery-by-identifier registration request without the pniIdentityKey field populated (null after deserialization).
Common situations: Older clients predating PNI that only send an ACI identity key; hand-written API requests omitting pniIdentityKey; serialization bugs where the key object fails to deserialize into the request DTO.
Understand the failure class
Background: "missing required argument" and "the following required arguments were not provided": what required-argument errors mean and how to fix them — this error's family across 20 libraries.
Related errors
- Recovery password required for authentication when…
- Recovery password required for for storage when recovering…
- account does not have a phone number
- Operation requires unauthenticated access
- must not use authenticated connection for anonymous…
AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09).
Data as JSON: /api/errors/91ab92c08894aae0.
Report an issue: GitHub.
Appendix: source
Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java:412
final String password,
final RegistrationRequest registrationRequest,
final String userAgent,
final String signalAgent) throws RegistrationLockFailureException, RateLimitExceededException {
if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) {
throw new BadRequestException("login purchases are not enabled");
}
if (ArrayUtils.isEmpty(registrationRequest.recoveryPassword())) {
throw new BadRequestException("Recovery password required for authentication when recovering an account by identifier");
}
if (registrationRequest.accountAttributes().recoveryPassword().isEmpty()) {
throw new BadRequestException("Recovery password required for for storage when recovering an account by identifier");
}
if (registrationRequest.pniIdentityKey() == null) {
throw new BadRequestException("Must specify a PNI-associated identity key when recovering an account by identifier");
}
final Account existingAccount = accounts.getByAccountIdentifier(accountIdentifier)
.orElseThrow(ForbiddenException::new);
final boolean passwordVerified = existingAccount.getAccountRecoveryPassword()
.map(saltedRecoveryPasswordHash -> PhoneNumberRecoveryPasswordsManager.verify(saltedRecoveryPasswordHash, registrationRequest.recoveryPassword()))
.orElse(false);
if (!passwordVerified) {
throw new ForbiddenException();
}
checkTotp(existingAccount, registrationRequest.totp());
if (!registrationRequest.skipDeviceTransfer() && existingAccount.hasCapability(DeviceCapability.TRANSFER)) {
// If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user)
// before we'll let them recover an account and start "from scratch"View on GitHub (pinned to 100ab61c82)