Billing Authorization
Which roles can view and manage billing, enforced via Better Auth org permissions and the Stripe plugin's authorizeReference hook
Billing in this monorepo is mounted by the @better-auth/stripe plugin (not hand-rolled API routes). Every subscription action the plugin exposes — listing, upgrading, cancelling, restoring, and opening the billing portal — is gated by a single authorization function, authorizeBillingAction, defined in packages/auth/billing-authorize.ts.
The core rule: billing is organization-scoped, and only org owners and admins can mutate it. In this app, members do not see billing — the org sidebar omits it unless organization: ["update"], and the billing page wraps the UI in OrgPermissionGate with that same permission.
The org billing page loads membership with getActiveOrganization(orgSlug) and wraps Change Plan / usage / actions in OrgPermissionGate (organization: ["update"]). That gate is UX only. The plugin's list-subscription Allow for members is unused API surface. The three thin auth.api.* wrappers (upgradeSubscriptionAction, openBillingPortalAction, cancelSubscriptionAction) rely on the plugin's authorizeReference — they do not also call hasOrgPermission. changePlanAction is the exception: it gates with hasOrgPermission because it does authorization-sensitive pre-work (guardDowngrade, member/storage reads) and can return early (cancel / blocked) without ever reaching auth.api.*.
Where authorization is wired
The Stripe plugin's subscription.authorizeReference callback in packages/auth/server.ts delegates every action to authorizeBillingAction:
authorizeReference: ({ user, referenceId, action }) =>
authorizeBillingAction({
userId: user.id,
referenceId,
action,
}),Here referenceId is the organization id (the plugin is configured with organization: { enabled: true }, so subscriptions are per-organization). authorizeReference wraps the five AuthorizeReferenceActions only. Stripe webhook /api/auth/stripe/webhook and /subscription/success do not call it.
The decision function
authorizeBillingAction in packages/auth/billing-authorize.ts is an exhaustive switch over the plugin's AuthorizeReferenceAction union. It routes each action to one of two database-backed checks:
isOrgMember(userId, organizationId)— true when amemberrow exists for thatuserId+organizationId. Used only for the read action.canManageBilling(userId, organizationId)— true only when that member'sroleisowneroradmin(ORG_ROLES.OWNER/ORG_ROLES.ADMIN). Used for every mutation and the portal.
The Member model has no suspendedAt field. Both helpers filter userId + organizationId (and canManageBilling then reads role). They do not add suspendedAt: null.
switch (action) {
case "list-subscription":
return await isOrgMember(userId, referenceId);
case "upgrade-subscription":
case "cancel-subscription":
case "restore-subscription":
case "billing-portal":
return await canManageBilling(userId, referenceId);
default: {
const _exhaustive: never = action;
console.warn(`[auth] Unknown subscription action denied: ${String(_exhaustive)}`);
return false;
}
}The default branch assigns action to a never, so any future plugin release that adds a new action breaks the TypeScript build until a role is explicitly chosen for it. At runtime, the same branch denies the unknown action and logs a warning — a belt-and-braces guard against type/runtime drift (covered by the "trial-extend" test case in packages/auth/billing-authorize.test.ts).
Authorization flow
Roles vs. billing capabilities
Capabilities below are derived directly from the switch routing and the canManageBilling role check, and match the allow/deny matrix in packages/auth/billing-authorize.test.ts (including scopes by userId and organizationId).
| Org role | View (list-subscription) | Upgrade | Cancel | Restore | Portal (billing-portal) |
|---|---|---|---|---|---|
owner | Allow | Allow | Allow | Allow | Allow |
admin | Allow | Allow | Allow | Allow | Allow |
member | Plugin Allow; no UI | Deny | Deny | Deny | Deny |
| non-member | Deny | Deny | Deny | Deny | Deny |
Notes:
- "Upgrade", "Cancel", and "Restore" map to the
upgrade-subscription,cancel-subscription, andrestore-subscriptionactions respectively; all four management actions share the samecanManageBillinggate. - A plain
membercannot view or manage billing in this app. Pluginlist-subscriptionwould allow them; the sidebar and page never expose it.
Relationship to org role permissions
The org roles themselves are defined in packages/auth/permissions.ts via orgAc.newRole(...). Note that billing is not expressed as a statement on the organization access-control roles (orgOwnerRole, orgAdminRole, orgMemberRole cover organization, member, invitation, and team — there is no billing statement on the org roles). Billing authorization is instead enforced imperatively by authorizeBillingAction reading the member's role directly.
The billing: ["manage", "view"] statements in permissions.ts belong to the platform-level admin access control (statement, adminRole, moderatorRole, userRole), a separate RBAC system from org-scoped billing and not what gates the Stripe plugin actions.
Second enforcement layer: changePlanAction only
changePlanAction in apps/web/app/[locale]/(app)/[orgSlug]/billing/actions.ts calls hasOrgPermission before any downgrade pre-work:
const allowed = await hasOrgPermission(validated.organizationId, {
organization: ["update"],
});
if (!allowed) {
return actionError("unauthorized");
}hasOrgPermission (in apps/web/lib/auth/auth.ts) calls auth.api.hasPermission — which resolves the caller's session from the request headers via the org plugin's session middleware — and returns a boolean, requiring the organization: ["update"] capability, which only owner and admin org roles hold per permissions.ts. That extra gate is needed because changePlanAction can return { action: "cancel" } or a blockReason without calling auth.api.*, so authorizeReference would never run.
The subsequent auth.api.upgradeSubscription call (paid→paid downgrade) then passes through authorizeBillingAction inside the plugin.
Source files
packages/auth/billing-authorize.ts—authorizeBillingAction,isOrgMember,canManageBillingpackages/auth/billing-authorize.test.ts— allow/deny matrix (userId+organizationId+ role)packages/auth/permissions.ts—ORG_ROLES, org and admin access-control role definitionspackages/auth/server.ts—subscription.authorizeReferencewiring into the Stripe pluginapps/web/app/[locale]/(app)/[orgSlug]/billing/page.tsx—getActiveOrganization,OrgPermissionGateapps/web/app/[locale]/(app)/[orgSlug]/billing/actions.ts—changePlanAction(hasOrgPermission); upgrade/portal/cancel rely onauthorizeReferenceapps/web/lib/auth/auth.ts—hasOrgPermissionhelperapps/web/lib/organization/queries.ts—getActiveOrganization