signalapp/Signal-Server · error · ForbiddenException

must not use authenticated connection for one-time donation…

Error message

must not use authenticated connection for one-time donation operations

What it means

One-time donation (boost) operations must be performed from an unauthenticated connection. createBoostPaymentIntent rejects the request with HTTP 403 if an authenticated account is present on the request.

Solutions

  1. Perform donation requests without Signal account authentication credentials
  2. Use a separate, unauthenticated HTTP client/session for donation endpoints
  3. Remove global auth-header interceptors for the donations API base URL

Example fix

// before
signalClient.post("/v1/donations/boost/payment_intent", request); // authed client
// after
unauthenticatedClient.post("/v1/donations/boost/payment_intent", request);
Defensive patterns

Strategy: validation

Validate before calling

if (hasAuthCredentials()) throw new Error('donations must not be authenticated');

Try / catch

try { await createBoost(intent); } catch (e) { if (e.status === 403) rethrowWithHint('send donation requests without Signal auth headers'); else throw e; }

Prevention

When it happens

Trigger: POSTing to the boost payment-intent endpoint while presenting valid Signal account credentials.

Common situations: Clients sending donations through a client that attaches auth headers globally; misconfigured HTTP client adding the Authorization/unidentified header to donation calls.

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/712b46f676841ef3. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java:185

      content = @Content(schema = @Schema(
          type = "object",
          properties = {
              @StringToClassMapItem(key = "error", value = String.class)
          })))
  @ApiResponse(responseCode = "401", description = "Donation permit was invalid or already spent")
  @RateLimitedByIp(RateLimiters.For.ONE_TIME_DONATION)
  @ManagedAsync
  public CreateBoostResponse createBoostPaymentIntent(
      @Auth final Optional<AuthenticatedDevice> authenticatedAccount,

      @Parameter(description = "A base64-encoded donation permit retrieved from POST /v1/donation/permit")
      @HeaderParam(HeaderUtils.DONATION_PERMIT) final Optional<DonationPermitHeader> donationPermitHeader,

      @NotNull @Valid final CreateBoostRequest request,
      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws SubscriptionInvalidAmountException {

    if (authenticatedAccount.isPresent()) {
      throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
    }

    SubscriptionsUtil.recordDonationPermitPresent(donationPermitHeader.isPresent(), "boostCreate", userAgent);
    final boolean spendSuccessful = donationPermitHeader.map(
            permitHeader -> {
              try {
                return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock);
              } catch (final VerificationFailedException e) {
                return false;
              }
            })
        .orElse(false);
    if (!spendSuccessful) {
      throw new WebApplicationException(Response.Status.UNAUTHORIZED);
    }

    validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), stripeManager);
    final PaymentIntent paymentIntent = stripeManager.createPaymentIntent(request.currency, request.amount,

View on GitHub (pinned to 100ab61c82)