There's a common pattern in starter kits: add a role column to the user table, hide admin links in the sidebar, maybe add a withAuth() wrapper. Call it RBAC.
It works in demos. It falls apart in production.
Hiding UI is not authorization
If your protection looks like this:
{user.role === "admin" && <AdminPanel />}
You don't have authorization. You have conditional rendering.
Anyone can call the API directly, replay a request, modify a client payload, or bypass your UI entirely. The browser is not a security boundary. The server is.
The real risk isn't malice
It's inconsistency.
You ship fast. You add a new route. You forget to guard it. Now you have one mutation properly scoped, another accidentally public, a third partially protected, and a fourth relying on client checks.
There's no system. Just scattered conditionals. And scattered conditionals decay.
Every team that's grown past two engineers has experienced this. The codebase gets large enough that nobody can hold the full authorization model in their head. You grep for "role" and find six different patterns. Some check before the query. Some check after. Some don't check at all.
What production authorization actually requires
Real multi-tenant SaaS needs four things:
- Tenant scoping at the data layer. Every query filtered by
orgId. - Explicit permission checks at mutation boundaries. Before the write, not after.
- A single, enforceable guard pattern. Not conventions. Not comments. A function that throws.
- Audit visibility into privileged actions. So you can answer "who did what, when."
The goal is a pattern that makes doing the wrong thing harder than doing the right thing.
What this looks like in code
SaaSCoreX defines 13 actions as a TypeScript union. Each role maps to a set of allowed actions. Every mutation goes through the same gate:
// 1. Resolve session and tenant context
const session = await requireOrg();
// 2. Get role in current org
const membership = await getMembership(
session.user.id, session.activeOrgId
);
// 3. Enforce — throws PermissionDeniedError if not allowed
requirePermission(membership.role, "project.delete");
// 4. Scoped query — only this org's data
await db.project.delete({
where: { id: projectId, orgId: session.activeOrgId },
});
This isn't a suggestion. It's the pattern every server action and API route follows. The UI can still hide buttons for better UX — but the server doesn't trust the UI. It enforces independently.
The key insight: requirePermission() is three lines of code. But it's the same three lines everywhere. That consistency is what makes it a system instead of a convention.
Why this is rare in starter kits
Templates optimize for getting to login quickly, showing Stripe working, and demo-friendly features. They're not optimized for 12 months of iteration, multiple engineers touching the code, or a future where you've forgotten why something was safe.
Authorization debt compounds quietly. Until it doesn't.
The principle
Security is not about hiding screens. It's about constraining mutations.
If your architecture does not enforce boundaries structurally, it relies on discipline. Discipline does not scale. Systems do.
See how SaaSCoreX enforces RBAC, tenant isolation, and eight other server-side subsystems on the architecture page.