saasprokit
Payment

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 a member row exists for that userId + organizationId. Used only for the read action.
  • canManageBilling(userId, organizationId) — true only when that member's role is owner or admin (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

list-subscription upgrade-subscriptioncancel-subscriptionrestore-subscriptionbilling-portal unknown action yes no no member yes owner admin member Subscription action requestuserId + referenceId orgId + action action type? isOrgMember canManageBilling default branch:console.warn + deny member row?userId + organizationId ALLOW DENY member row?userId + organizationId role?

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 roleView (list-subscription)UpgradeCancelRestorePortal (billing-portal)
ownerAllowAllowAllowAllowAllow
adminAllowAllowAllowAllowAllow
memberPlugin Allow; no UIDenyDenyDenyDeny
non-memberDenyDenyDenyDenyDeny

Notes:

  • "Upgrade", "Cancel", and "Restore" map to the upgrade-subscription, cancel-subscription, and restore-subscription actions respectively; all four management actions share the same canManageBilling gate.
  • A plain member cannot view or manage billing in this app. Plugin list-subscription would 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.

alt [not owner/admin] [owner/admin] change plan(orgId, targetPlan) hasPermission(orgId, organization:[update]) true / false actionError("unauthorized") upgradeSubscription(referenceId = orgId) authorizeReference(userId, orgId, "upgrade-subscription") canManageBilling -> true/false result / rejection ActionResult Billing UI changePlanAction (Server Action) hasOrgPermission Stripe plugin (auth.api.upgradeSubscription) authorizeBillingAction

Source files

  • packages/auth/billing-authorize.tsauthorizeBillingAction, isOrgMember, canManageBilling
  • packages/auth/billing-authorize.test.ts — allow/deny matrix (userId + organizationId + role)
  • packages/auth/permissions.tsORG_ROLES, org and admin access-control role definitions
  • packages/auth/server.tssubscription.authorizeReference wiring into the Stripe plugin
  • apps/web/app/[locale]/(app)/[orgSlug]/billing/page.tsxgetActiveOrganization, OrgPermissionGate
  • apps/web/app/[locale]/(app)/[orgSlug]/billing/actions.tschangePlanAction (hasOrgPermission); upgrade/portal/cancel rely on authorizeReference
  • apps/web/lib/auth/auth.tshasOrgPermission helper
  • apps/web/lib/organization/queries.tsgetActiveOrganization

On this page