signalapp/Signal-Server · error · WebApplicationException

Invalid signature

Error message

Invalid signature

What it means

In AccountControllerV2.changeNumber, every signed pre-key included in the change-number request must carry a valid signature. If request.isSignatureValidOnEachSignedPreKey(userAgentString) fails for any pre-key, the server returns HTTP 422 with body 'Invalid signature'.

Solutions

  1. Regenerate the identity key pair and re-sign all signed pre-keys with the new private identity key on the client.
  2. Verify the client's signature computation matches the expected format (key bytes + signature over the correct input).
  3. Update the client library to the version matching the server's pre-key signature scheme.
  4. Inspect each SignedPreKey in the request and remove/replace ones with invalid signatures before retrying.

Example fix

// before
signedPreKey.setSignature(sign(oldIdentityPrivateKey, signedPreKey.getPublicKey()));
// after
signedPreKey.setSignature(sign(newIdentityPrivateKey, signedPreKey.getSerializedPublicKey()));
Defensive patterns

Strategy: try-catch

Validate before calling

for (const k of request.signedPreKeys) {
  if (!lib.verifySignature(k.publicKey, k.signature, identityKeyPair.publicKey)) throw new Error('pre-key signature invalid before send');
}

Type guard

const hasValidPreKeySignature = (k, idPub) => k.signature && verify(k.serializedPublicKey, k.signature, idPub);

Try / catch

try { await changeNumber(req); } catch (e) { if (e.status === 422) { await regenerateAndResignPreKeys(identityKeyPair); return changeNumber(req); } throw e; }

Prevention

When it happens

Trigger: PUT /v2/accounts/phone_number/{number} (change number) where one or more signed pre-keys in the request have signatures that don't verify against their public key, or the signature input (including the UA string on some versions) doesn't match what the client signed.

Common situations: Client crypto library generating malformed pre-key signatures, keys serialized/reserialized in a way that alters the signed bytes, upgrading identity key without re-signing pre-keys, or client/server version skew in signature format.

Related errors


AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09). Data as JSON: /api/errors/2c10e617b3c085e5. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java:98

  @ApiResponse(responseCode = "410", description = "Mismatched registration ids in 'devices to notify' list", content = @Content(schema = @Schema(implementation = StaleDevicesResponse.class)))
  @ApiResponse(responseCode = "413", description = "One or more device messages was too large")
  @ApiResponse(responseCode = "422", description = "The request did not pass validation")
  @ApiResponse(responseCode = "423", content = @Content(schema = @Schema(implementation = RegistrationLockFailure.class)))
  @ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header(
      name = "Retry-After",
      description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
  public AccountIdentityResponse changeNumber(@Auth final AuthenticatedDevice authenticatedDevice,
      @NotNull @Valid final ChangeNumberRequest request,
      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,
      @Context final ContainerRequestContext requestContext)
      throws RateLimitExceededException, InterruptedException, RegistrationLockFailureException {

    if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
      throw new ForbiddenException();
    }

    if (!request.isSignatureValidOnEachSignedPreKey(userAgentString)) {
      throw new WebApplicationException("Invalid signature", 422);
    }

    try {
      final Account updatedAccount = changeNumberManager.changeNumber(
          authenticatedDevice.accountIdentifier(),
          StringUtils.isNotBlank(request.sessionId()) ? request.decodeSessionId() : null,
          request.recoveryPassword(),
          request.registrationLock(),
          request.number(),
          request.pniIdentityKey(),
          request.devicePniSignedPrekeys(),
          request.devicePniPqLastResortPrekeys(),
          request.deviceMessages(),
          request.pniRegistrationIds(),
          userAgentString,
          requestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE),
          (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME));

View on GitHub (pinned to 100ab61c82)