Fourteen SystemsFourteen Systems
← Back to blog
/
securityarchitecturerbac

Most SaaS Authorization Is UI Theater

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:

  1. Tenant scoping at the data layer. Every query filtered by orgId.
  2. Explicit permission checks at mutation boundaries. Before the write, not after.
  3. A single, enforceable guard pattern. Not conventions. Not comments. A function that throws.
  4. 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.

See the full architecture

Every system discussed here is production code inside SaaSCoreX.