high · 7.1Jul 24, 2026

@better-auth/stripe: Cross-Organization Billing Authorization Bypass

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A signed-in member of multiple organizations can cancel, change, restore, or open the billing portal for an org they do not manage, by passing a permitted org ID in the query string while the server…

Package@better-auth/stripe
Ecosystemnpm
Affected>= 1.4.11, < 1.6.21
Fixed in1.6.21
@better-auth/stripe: Cross-Organization Billing Authorization Bypass

The problem

The Stripe plugin's org subscription actions run a two-step check: a middleware reads the org ID from the query string or body and calls your authorizeReference callback, then the route handler reads the org ID from the body only, falling back to session.activeOrganizationId when the body has no ID.

This split means an attacker can pass a permitted org in the query string to satisfy the authorization check, while leaving the body empty so the handler acts on their active (target) org instead. The actions affected are cancel, change plan, restore, and open billing portal.

The target is always an org the caller already belongs to.

Proof of concept

A working proof-of-concept for this issue in @better-auth/stripe, with the exact payload below.

http
# Step 1: attacker sets their active org to VICTIM_ORG_ID (an org they belong to but cannot manage billing for)
# Step 2: send the action request with ALLOWED_ORG_ID (an org they CAN manage) in the query string, no organizationId in the body

POST /api/auth/stripe/cancel-subscription?organizationId=ALLOWED_ORG_ID HTTP/1.1
Host: target.example.com
Cookie: better-auth.session=<attacker_session>
Content-Type: application/json

{
  "subscriptionId": "sub_victim123"
}

# Middleware sees ALLOWED_ORG_ID in the query string -> authorizeReference passes
# Handler finds no organizationId in the body -> falls back to session.activeOrganizationId (VICTIM_ORG_ID)
# Cancellation runs against VICTIM_ORG_ID, not ALLOWED_ORG_ID

The root cause is CWE-639 / CWE-863: the authorization check and the action execution read the controlling key from different sources. The middleware trusts the query string; the handler trusts the body (or session). The patch (commit 29fbcb5) resolves the org ID exactly once inside the middleware and threads that single resolved value through to the handler, so the approved org and the acted-on org are always the same object.

Before the fix, no amount of validation in authorizeReference could close the gap, because the callback was given a different ID than the one the handler would eventually use.

The fix

Upgrade to @better-auth/stripe@1.6.21 (stable) or @better-auth/stripe@1.7.0-beta.10 (beta). If you cannot upgrade immediately, add the following guard to your authorizeReference callback to force both IDs to agree:

``ts if (referenceId !== session.activeOrganizationId) { return false; } ``

This workaround works because it makes the only org that can pass authorization also be the org the handler will act on.

Reported by Taesu (@bytaesu).

References: [1][2][3][4][5]

Related research