kriasoft/react-starter-kit · error · TRPCError
FORBIDDEN
FORBIDDEN
Error message
Not a member of the active organization
What it means
The billing subscription query accepts ctx.session.activeOrganizationId as the billing scope, but a session outlives a membership removal. Before trusting that org, it looks up the member row for (organizationId, userId); if none exists it throws TRPCError FORBIDDEN. Membership, not the session pointer, is the authorization proof.
Source
Thrown at apps/api/routers/billing.ts:55
let canManage = true;
// The active organization selects the billing scope; it does not prove the
// caller still belongs to it. A session outlives a membership removal, so
// without this an ex-member keeps reading their old organization's plan.
// `ctx.db`, never `dbCached` – a stale answer here is an authorization hole.
// The role rides along on the same lookup: every member may see the plan,
// but only owners and admins may change it, and the UI has no other way to
// know that before Better Auth rejects the checkout.
if (organizationId) {
const membership = await ctx.db.query.member.findFirst({
columns: { role: true },
where: (m, { and, eq }) =>
and(eq(m.organizationId, organizationId), eq(m.userId, ctx.user.id)),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Not a member of the active organization",
});
}
canManage = canManageOrgBilling(membership.role);
}
const referenceId = organizationId ?? ctx.user.id;
const sub = await ctx.db.query.subscription.findFirst({
where: (s, { eq, and, inArray }) =>
and(
eq(s.referenceId, referenceId),
inArray(s.status, ["active", "trialing"]),
),
});
const plan = sub?.plan ?? "free";View on GitHub (pinned to 0aa7603435)
Solutions
- Clear the stale active organization: sign out and back in, or call the Better Auth organization setActive endpoint with a valid org (or null)
- Verify the membership actually exists (member table row for that userId + organizationId); recreate it if the removal was unintentional
- On the client, catch FORBIDDEN and fall back to personal billing scope instead of looping on the stale org
- Audit membership-removal flows to also reset activeOrganizationId for affected sessions
Example fix
// client
catch (e) {
if (e.data?.code === 'FORBIDDEN') {
await authClient.organization.setActive({ organizationId: null }); // drop stale org
return trpc.billing.subscription.query();
}
throw e;
} Defensive patterns
Strategy: try-catch
Validate before calling
// confirm membership before rendering org-scoped billing UI
const memberships = await authClient.organization.list();
const activeId = session.activeOrganizationId;
const stillMember = memberships?.data?.some((m) => m.id === activeId);
if (activeId && !stillMember) {
await authClient.organization.setActive({ organizationId: null }); // fall back to personal
} Type guard
function isForbidden(error: unknown): error is { code: 'FORBIDDEN'; message: string } {
return (
typeof error === 'object' && error !== null &&
'code' in error && (error as { code?: string }).code === 'FORBIDDEN'
);
} Try / catch
try {
return await trpc.billing.subscription.query();
} catch (error) {
if (isTRPCClientError(error) && error.data?.code === 'FORBIDDEN') {
// stale active org: clear it and retry with personal scope
await authClient.organization.setActive({ organizationId: null });
return trpc.billing.subscription.query();
}
throw error;
} Prevention
- Reset activeOrganizationId whenever a membership is removed (webhook or removal flow)
- Never treat session state as membership proof — the server must re-check the member table
- On the client, handle FORBIDDEN by falling back to personal scope instead of retrying forever
- Re-issue sessions after org removal where feasible so stale pointers expire quickly
When it happens
Trigger: Calling billing.subscription while ctx.session.activeOrganizationId points at an organization the user is no longer (or never was) a member of — typically after being removed from the org without the session being refreshed.
Common situations: An ex-employee keeps an old tab open; the organization was deleted while activeOrganizationId still references it; a stale/hand-crafted session; or a webhook removed the membership but the session wasn't re-created.
Related errors
AI-assisted analysis of kriasoft/react-starter-kit@0aa7603435 (2026-08-31).
Data as JSON: /api/errors/f516bd59f1f57d46.
Report an issue: GitHub.