signalapp/Signal-Server · error · BackupPermissionException

Only primary device can set backup-id

Error message

Only primary device can set backup-id

What it means

BackupAuthManager.commitBackupId only allows the account's primary device to set (commit) a backup-id. When a linked (non-primary) device calls it, a BackupPermissionException is thrown to prevent linked devices from taking over backup configuration.

Solutions

  1. Perform backup-id setup on the primary device; route the request through the primary or have the primary do it first.
  2. Check Device#isPrimary() client-side before calling commit and show an appropriate error otherwise.
  3. Ensure the authenticated device in the request is the primary; if not, return a 403-style response to the user.

Example fix

// before
backupAuthManager.commitBackupId(account, device, messagesReq, mediaReq);
// after
if (!device.isPrimary()) {
  throw new WebApplicationException("backup setup must be done from primary device", 403);
}
backupAuthManager.commitBackupId(account, device, messagesReq, mediaReq);
Defensive patterns

Strategy: try-catch

Validate before calling

if (!device.isPrimary()) {
  throw new WebApplicationException("backup setup must be performed from the primary device", 403);
}

Type guard

boolean isPrimary(Device d) {
  return d != null && d.isPrimary();
}

Try / catch

try {
  backupAuthManager.commitBackupId(account, device, messagesReq, mediaReq);
} catch (BackupPermissionException e) {
  return Response.status(403).entity("primary device required").build();
}

Prevention

When it happens

Trigger: A linked device (device.isPrimary() == false) invoking PUT /v1/backups/ or commitBackupId to set a new backup-id / backup auth credential request.

Common situations: Client SDKs performing backup setup on a secondary device (desktop/iPad link); fleet rollouts where the wrong device performs initialization; testing with a linked device assuming parity with primary.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java:107

  /**
   * Store credential requests containing blinded backup-ids for future use.
   *
   * @param account                         The account using the backup-id
   * @param device                          The device setting the account backup-id
   * @param messagesBackupCredentialRequest A request containing the blinded backup-id the client will use to upload
   *                                        message backups
   * @param mediaBackupCredentialRequest    A request containing the blinded backup-id the client will use to upload
   *                                        media backups
   * @throws RateLimitExceededException If too many backup-ids have been committed
   */
  public void commitBackupId(
      final Account account,
      final Device device,
      final Optional<BackupAuthCredentialRequest> messagesBackupCredentialRequest,
      final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest)
      throws RateLimitExceededException, BackupPermissionException, BackupInvalidArgumentException {
    if (!device.isPrimary()) {
      throw new BackupPermissionException("Only primary device can set backup-id");
    }

    if (messagesBackupCredentialRequest.isEmpty() && mediaBackupCredentialRequest.isEmpty()) {
      throw new BackupInvalidArgumentException("Must set at least one of message/media credential requests");
    }

    final byte[] storedMessageCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)
        .orElse(null);
    final byte[] storedMediaCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MEDIA)
        .orElse(null);

    // If the provided credential request is null, we want to set to the existing request
    final byte[] targetMessageCredentialRequest = messagesBackupCredentialRequest
        .map(BackupAuthCredentialRequest::serialize)
        .orElse(storedMessageCredentialRequest);
    final byte[] targetMediaCredentialRequest = mediaBackupCredentialRequest
        .map(BackupAuthCredentialRequest::serialize)
        .orElse(storedMediaCredentialRequest);

View on GitHub (pinned to 100ab61c82)