signalapp/Signal-Server · error · ForbiddenException

Only primary devices may register attestations

Error message

Only primary devices may register attestations

What it means

DeviceCheckController.attest registers a DCAppAttest attestation for a device. Apple's DeviceCheck attestation flow is anchored to the account's primary device, so the endpoint explicitly rejects calls from linked devices by checking authenticatedDevice.deviceId() != Device.PRIMARY_ID and throwing ForbiddenException.

Solutions

  1. Perform DeviceCheck attestation only from the primary device associated with the account
  2. Check that the authenticated credentials belong to the primary device before calling attest (deviceId == 1)
  3. If the primary device changed, re-register/re-link and attest from the new primary device
  4. If attestation is intended for linked devices, this endpoint is the wrong one - use a capability that supports linked devices

Example fix

// before (linked device)
final AuthenticatedDevice device = auth.authenticate(badCredentials); // deviceId = 2
client.attest(device, keyId, attestation); // 403 ForbiddenException
// after
final AuthenticatedDevice device = auth.authenticate(primaryCredentials); // deviceId = 1
if (device.deviceId() != Device.PRIMARY_ID) throw new IllegalStateException("attest requires primary device");
client.attest(device, keyId, attestation);
Defensive patterns

Strategy: validation

Validate before calling

if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
  throw new IllegalStateException("DeviceCheck attestation must be performed from the primary device");
}
client.attest(authenticatedDevice, keyId, attestation);

Type guard

boolean isPrimaryDevice(AuthenticatedDevice d) { return d != null && d.deviceId() == Device.PRIMARY_ID; }

Prevention

When it happens

Trigger: A linked (non-primary) device calls POST /v1/devicecheck/attest with a keyId and attestation payload; the authenticated device's deviceId is anything other than Device.PRIMARY_ID (1).

Common situations: A client library performs devicecheck attestation on a linked iPad/companion device; a session token resolves to a secondary device; automation or tests authenticate as a non-primary device.

Understand the failure class

Background: "You do not have permission" / 403 Forbidden errors: authenticated but not allowed — causes and fixes across open-source libraries — this error's family across 31 libraries.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java:145

  @ApiResponse(responseCode = "204", description = "The keyId was successfully added to the account")
  @ApiResponse(responseCode = "410", description = "There was no challenge associated with the account. It may have expired.")
  @ApiResponse(responseCode = "401", description = "The attestation could not be verified")
  @ApiResponse(responseCode = "413", description = "There are too many unique keyIds associated with this account. This is an unrecoverable error.")
  @ApiResponse(responseCode = "409", description = "The provided keyId has already been registered to a different account")
  @ManagedAsync
  public void attest(
      @Auth final AuthenticatedDevice authenticatedDevice,

      @Valid
      @NotNull
      @Parameter(description = "The keyId, encoded with padded url-safe base64")
      @QueryParam("keyId") final String keyId,

      @RequestBody(description = "The attestation data, created by [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))")
      @NotNull final byte[] attestation) {

    if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
      throw new ForbiddenException("Only primary devices may register attestations");
    }

    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));

    try {
      deviceCheckManager.registerAttestation(account, parseKeyId(keyId), attestation);
    } catch (TooManyKeysException e) {
      throw new WebApplicationException(Response.status(413).build());
    } catch (ChallengeNotFoundException e) {
      throw new WebApplicationException(Response.status(410).build());
    } catch (DeviceCheckVerificationFailedException e) {
      throw new WebApplicationException(e.getMessage(), Response.status(401).build());
    } catch (DuplicatePublicKeyException e) {
      throw new WebApplicationException(Response.status(409).build());
    }
  }

View on GitHub (pinned to 100ab61c82)