{"record":{"id":"f516bd59f1f57d46","repo":"kriasoft/react-starter-kit","slug":"forbidden","errorCode":"FORBIDDEN","errorMessage":"Not a member of the active organization","messagePattern":"Not a member of the active organization","errorType":"error_code","errorClass":"TRPCError","httpStatus":403,"severity":"error","filePath":"apps/api/routers/billing.ts","lineNumber":55,"sourceCode":"    let canManage = true;\n\n    // The active organization selects the billing scope; it does not prove the\n    // caller still belongs to it. A session outlives a membership removal, so\n    // without this an ex-member keeps reading their old organization's plan.\n    // `ctx.db`, never `dbCached` – a stale answer here is an authorization hole.\n    // The role rides along on the same lookup: every member may see the plan,\n    // but only owners and admins may change it, and the UI has no other way to\n    // know that before Better Auth rejects the checkout.\n    if (organizationId) {\n      const membership = await ctx.db.query.member.findFirst({\n        columns: { role: true },\n        where: (m, { and, eq }) =>\n          and(eq(m.organizationId, organizationId), eq(m.userId, ctx.user.id)),\n      });\n\n      if (!membership) {\n        throw new TRPCError({\n          code: \"FORBIDDEN\",\n          message: \"Not a member of the active organization\",\n        });\n      }\n\n      canManage = canManageOrgBilling(membership.role);\n    }\n\n    const referenceId = organizationId ?? ctx.user.id;\n\n    const sub = await ctx.db.query.subscription.findFirst({\n      where: (s, { eq, and, inArray }) =>\n        and(\n          eq(s.referenceId, referenceId),\n          inArray(s.status, [\"active\", \"trialing\"]),\n        ),\n    });\n\n    const plan = sub?.plan ?? \"free\";","sourceCodeStart":37,"sourceCodeEnd":73,"githubUrl":"https://github.com/kriasoft/react-starter-kit/blob/0aa7603435f16159ad0b8fef68fb7f6280be7ca1/apps/api/routers/billing.ts#L37-L73","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// client\ncatch (e) {\n  if (e.data?.code === 'FORBIDDEN') {\n    await authClient.organization.setActive({ organizationId: null }); // drop stale org\n    return trpc.billing.subscription.query();\n  }\n  throw e;\n}","handlingStrategy":"try-catch","validationCode":"// confirm membership before rendering org-scoped billing UI\nconst memberships = await authClient.organization.list();\nconst activeId = session.activeOrganizationId;\nconst stillMember = memberships?.data?.some((m) => m.id === activeId);\nif (activeId && !stillMember) {\n  await authClient.organization.setActive({ organizationId: null }); // fall back to personal\n}","typeGuard":"function isForbidden(error: unknown): error is { code: 'FORBIDDEN'; message: string } {\n  return (\n    typeof error === 'object' && error !== null &&\n    'code' in error && (error as { code?: string }).code === 'FORBIDDEN'\n  );\n}","tryCatchPattern":"try {\n  return await trpc.billing.subscription.query();\n} catch (error) {\n  if (isTRPCClientError(error) && error.data?.code === 'FORBIDDEN') {\n    // stale active org: clear it and retry with personal scope\n    await authClient.organization.setActive({ organizationId: null });\n    return trpc.billing.subscription.query();\n  }\n  throw error;\n}","preventionTips":["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"],"tags":["trpc","authorization","forbidden","organization"],"backgroundTag":"organization-membership-required","analyzedSha":"0aa7603435f16159ad0b8fef68fb7f6280be7ca1","analyzedAt":"2026-08-31T21:50:55.742Z","schemaVersion":2},"datasetVersion":"2026-08-31T22:30:34.772Z"}