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

  1. Clear the stale active organization: sign out and back in, or call the Better Auth organization setActive endpoint with a valid org (or null)
  2. Verify the membership actually exists (member table row for that userId + organizationId); recreate it if the removal was unintentional
  3. On the client, catch FORBIDDEN and fall back to personal billing scope instead of looping on the stale org
  4. 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

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.