signalapp/Signal-Server · error · BadRequestException

may not provide both group send token and unidentified…

Error message

may not provide both group send token and unidentified access key

What it means

When fetching an unversioned profile, a caller may authenticate either with a group send token or with an unidentified access key, but not both. Supplying both is ambiguous, so the controller rejects it with a 400 BadRequest before validating either credential.

Solutions

  1. Send exactly one credential: drop the unidentified access key when a group send token is present
  2. If using a group send token, clear/remove any default unidentified access key header the client library attaches
  3. Update client code so token selection is exclusive based on the lookup context (group-based vs access-key-based)

Example fix

// before
request.header("X-Signal-Group-Send-Token", token).header("Authorization-Unidentified", accessKey);
// after
if (groupSendToken != null) { request.header("X-Signal-Group-Send-Token", token); } else { request.header("Authorization-Unidentified", accessKey); }
Defensive patterns

Strategy: validation

Validate before calling

if (groupSendToken != null && unidentifiedAccessKey != null) {
  throw new IllegalArgumentException("provide either groupSendToken or unidentifiedAccessKey, not both");
}

Type guard

boolean exactlyOneCredential(String groupSendToken, String accessKey) { return (groupSendToken != null) ^ (accessKey != null); }

Try / catch

try { /* profile fetch */ } catch (BadRequestException e) { if (e.getMessage().contains("both group send token")) { retryWithSingleCredential(); } }

Prevention

When it happens

Trigger: GET /v1/profile/{identifier} with both a GroupSendToken (header/query) and an unidentified access key (Authorization-Unidentified header) present in the same request.

Common situations: Clients that always attach their unidentified access key and additionally add a group send token; middleware or libraries that inject default auth headers conflicting with explicit tokens.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java:342

  @ManagedAsync
  public BaseProfileResponse getUnversionedProfile(
      @Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,
      @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
      @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
      @Context ContainerRequestContext containerRequestContext,
      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
      @PathParam("identifier") ServiceIdentifier identifier)
      throws RateLimitExceededException {

    final Optional<Account> maybeRequester =
        maybeAuthenticatedDevice.map(
            authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
                .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));

    final Account targetAccount;
    if (groupSendToken.isPresent()) {
      if (accessKey.isPresent()) {
        throw new BadRequestException("may not provide both group send token and unidentified access key");
      }
      try {
        final GroupSendFullToken token = groupSendToken.get().token();
        token.verify(List.of(identifier.toLibsignal()), clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams));
        targetAccount = accountsManager.getByServiceIdentifier(identifier).orElseThrow(NotFoundException::new);
      } catch (VerificationFailedException e) {
        throw new NotAuthorizedException(e);
      }
    } else {
      targetAccount = verifyPermissionToReceiveProfile(
          maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier, "getUnversionedProfile", userAgent);
    }
    return switch (identifier.identityType()) {
      case ACI -> buildBaseProfileResponseForAccountIdentity(targetAccount,
          maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getAccountIdentifier(), identifier)).orElse(false),
          containerRequestContext);
      case PNI -> buildBaseProfileResponseForPhoneNumberIdentity(targetAccount);
    };

View on GitHub (pinned to 100ab61c82)