signalapp/Signal-Server · error · IllegalArgumentException

Only primary devices can link devices

Error message

Only primary devices can link devices

What it means

AccountsManager.waitForNewLinkedDevice throws IllegalArgumentException("Only primary devices can link devices") when the device performing the link is not the primary device. Linked (secondary) devices are not permitted to authorize additional device links; only the primary can approve new linkings.

Solutions

  1. Ensure the link-device request is handled by the primary device's session/auth context
  2. Verify device.isPrimary() before invoking waitForNewLinkedDevice and return a client error otherwise
  3. Fix clients so secondary devices redirect linking to the primary device
  4. Correct test harnesses to use a primary Device instance

Example fix

// before
accounts.waitForNewLinkedDevice(accountUuid, authenticatedDevice, token, timeout); // may be secondary
// after
if (!authenticatedDevice.isPrimary()) {
  throw new WebApplicationException(Response.status(403).build());
}
accounts.waitForNewLinkedDevice(accountUuid, authenticatedDevice, token, timeout);
Defensive patterns

Strategy: validation

Validate before calling

// Ensure the authenticated device is primary before initiating a link
if (!authenticatedDevice.isPrimary()) {
  throw new WebApplicationException(Response.status(403).build());
}

Type guard

static boolean canInitiateDeviceLink(Device d) {
  return d.isPrimary();
}

Try / catch

try {
  return accounts.waitForNewLinkedDevice(accountUuid, device, token, timeout);
} catch (IllegalArgumentException e) {
  if (e.getMessage().equals("Only primary devices can link devices")) {
    return CompletableFuture.failedFuture(new WebApplicationException(Response.status(403).build()));
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling waitForNewLinkedDevice() with a linkingDevice whose isPrimary() is false — e.g. a secondary device initiating the link-device handshake.

Common situations: Client bugs where the link request is routed from the wrong device; duplicated provisioning code running on a linked device; reversed arguments when constructing the linking Device object in tests.

Understand the failure class

Background: UnsupportedOperationException and "is not supported" errors: when a library deliberately refuses a call — this error's family across 30 libraries.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java:1708

    final List<String> keysToDelete = new ArrayList<>(2);
    account.getPhoneNumberIdentifier()
        .map(pni -> getAccountMapKey(pni.toString()))
        .ifPresent(keysToDelete::add);
    keysToDelete.add(getAccountEntityKey(account.getAccountIdentifier()));

    ResilienceUtil.getGeneralRedisRetry(RETRY_NAME).executeRunnable(() ->
        redisDeleteTimer.record(() ->
            cacheCluster.useCluster(connection ->
                connection.sync().del(keysToDelete.toArray(String[]::new)))));
  }

  public CompletableFuture<Optional<DeviceInfo>> waitForNewLinkedDevice(
      final UUID accountIdentifier,
      final Device linkingDevice,
      final String linkDeviceTokenIdentifier,
      final Duration timeout) {
    if (!linkingDevice.isPrimary()) {
      throw new IllegalArgumentException("Only primary devices can link devices");
    }

    // Unbeknownst to callers but beknownst to us, the "link device token identifier" is the base64/url-encoded SHA256
    // hash of a device-linking token. Before we use the string anywhere, make sure it's the right "shape" for a hash.
    if (Base64.getUrlDecoder().decode(linkDeviceTokenIdentifier).length != SHA256_HASH_LENGTH) {
      return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid token identifier"));
    }

    final Instant deadline = clock.instant().plus(timeout);
    final CompletableFuture<Optional<DeviceInfo>> deviceAdded = waitForPubSubKey(waitForDeviceFuturesByTokenIdentifier,
        linkDeviceTokenIdentifier, getLinkedDeviceKey(linkDeviceTokenIdentifier), timeout, this::handleDeviceAdded);

    return deviceAdded.thenCompose(maybeDeviceInfo -> maybeDeviceInfo.map(deviceInfo -> {
          // The device finished linking, we now want to make sure the primary client has fetched messages that could
          // have come in before the linked device's mailbox was set up. This avoids a race where the linked device
          // misses out on messages that were sent before its mailbox was set up but received by the primary *after*
          // creating its backup for the linked device.

View on GitHub (pinned to 100ab61c82)