Un-F*ck Your Authorization Logic

Stop branching on roles.
Start enforcing on permissions.
Keep your IdP swappable.
TL;DR
- Roles are delivery mechanisms, not business logic.
- Enforce on permissions only (domain:resource:action).
- Keep a single, typed permission catalog in code, used by both FE and BE.
- Bundle permissions into roles in your IdP for assignment convenience only.
- Ship now with manual mapping; graduate to IaC sync later, without changing runtime auth.
The Problem: Role Checks Rot Your Codebase
Role checks feel simple… until they don’t. A handful of roles becomes a mess of if/else
and switch/case
statements sprinkled across controllers, services, and components.
The moment a PM asks, “Can Editors export financial reports if they’re in Ops but not Contractors?” your “simple” role model mutates into spaghetti.
“Why not just check roles? Changing a role’s permissions is a data change.”
Because role checks couple code to labels. The moment you pivot role definitions, code must change. If you check effective permissions instead, roles can change freely in the IdP and your app doesn’t care.
Key insight: Roles are just buckets. Permissions are the contract.
How It Should Work
Architecture
- Auth (frontend-first): Your FE authenticates with the IdP (e.g., Clerk/Keycloak). The BE validates the IdP’s tokens (middleware or OIDC verification).
- Permission-first authorization: Define a code-level catalog (e.g.,
org:project:create
,org:project:delete
,org:task:create
).- Backend enforces at API/service boundaries (middleware/decorators).
- Frontend gates routes/components using the same catalog.
- Roles as bundles: In the IdP, roles only group permissions for easier assignment. The application never branches on role names.
Today vs. Later
- Today: Define permissions in code; map permissions → roles in Clerk/Keycloak manually (or with a script).
- Later (Roadmap): IaC-driven sync keeps the IdP in lockstep with your code-defined catalog.
Why This Architecture Wins
- Stability & safety: Permission checks survive role churn and re-labeling.
- Single contract across tiers: One source of truth keeps FE/BE aligned and prevents “UI allows / API denies” drift.
- Portability: Swap IdPs (Clerk ↔ Keycloak) by replacing the adapter that populates effective permissions—not your business logic.
- Gradual ops maturity: Start manual to deliver value; add IaC sync later for repeatability and review.
The Footgun: Role Checks in Code
Here’s what “just a few roles” turns into:
// ❌ Role-driven branching explodes over time
const requireReportsAccess =
() => (req, res, next) => {
const roles = new Set(req.user?.roles ?? []);
if (roles.has("Suspended") || roles.has("Terminated")) {
return res.status(403).json({ error: "account not in good standing" });
}
if (roles.has("Admin") && !roles.has("Temp")) return next();
if (roles.has("Manager") && !roles.has("Temp")) return next();
if (
roles.has("Analyst") &&
(roles.has("FinanceTeam") || roles.has("OpsTeam")) &&
!roles.has("Contractor")
) return next();
if (roles.has("Viewer")) {
if (req.method !== "GET") return res.status(403).json({ error: "read-only" });
const isFinancial = /\/reports\/financial\b/.test(req.path);
if (isFinancial && !roles.has("SOXExempt") && !roles.has("EmergencyOverride")) {
return res.status(403).json({ error: "no financial clearance" });
}
return next();
}
return res.status(403).json({ error: "no matching role policy" });
};
Every new nuance = another conditional. Now compare a permission-first guard:
// ✅ Permission-driven guard: stable, explicit, testable
export enum Permission {
ReportsRead = "reports:read",
ReportsFinancialRead = "reports.financial:read",
ProjectCreate = "project:create",
ProjectDelete = "project:delete",
TaskCreate = "task:create",
}
export const requirePermission =
(...needed: Permission[]) =>
(req, res, next) => {
const have = new Set<string>(req.user?.permissions ?? []);
const missing = needed.filter(p => !have.has(p));
return missing.length
? res.status(403).json({ error: "Forbidden", missing })
: next();
};
// Usage
app.get("/reports", requirePermission(Permission.ReportsRead), okHandler);
app.get("/reports/financial", requirePermission(Permission.ReportsFinancialRead), okHandler);
You never check “Admin” or “Analyst.” You check what the action requires.
“But Our Roles Have 10–12 Permissions Each”
Great! That’s exactly why you don’t want those 10–12 rules embedded in code. You don’t check “all of them” at once—you check the single permission needed for this action. Role contents can evolve in the IdP without a code change. If you later need “all the things,” introduce a composite permission (e.g., project:admin
) sparingly to avoid OR
bloat.
The Contract: Typed Permission Catalog
Keep it defined once, used everywhere.
// /auth/permissions.ts
export const Permissions = {
Reports: {
Read: "reports:read",
FinancialRead: "reports.financial:read",
},
Project: {
Create: "project:create",
Delete: "project:delete",
},
Task: {
Create: "task:create",
},
} as const;
export type Permission = typeof Permissions[keyof typeof Permissions][keyof typeof Permissions[keyof typeof Permissions]];
- Backend imports this for middleware/decorators.
- Frontend imports the same for route/component guards.
React route/component guard example:
// Require.tsx
import { useAuth } from "./useAuth"; // returns { permissions: string[] }
import { type Permission } from "./permissions";
export function Require({ anyOf, children }: { anyOf: Permission[]; children: React.ReactNode }) {
const { permissions } = useAuth();
const have = new Set(permissions ?? []);
const ok = anyOf.some(p => have.has(p));
return ok ? <>{children}</> : null;
}
// usage
<Require anyOf={[Permissions.Reports.Read]}>
<ReportsPage />
</Require>
IdP Details
- Clerk: Store permissions as custom claims or via roles that bundle permissions. Use JWT templates to embed effective permissions for the active org/session.
- Keycloak: Represent permissions as client/realm roles or as authorization permissions; use protocol mappers to stamp effective permissions into the access token.
- Server fallback: If token size is a concern, cache short-lived lookups server-side (still enforce on permissions, never on role names).
Either way, your app consumes permissions: string[]
. Swapping IdPs means swapping the adapter that fills this array—not your guards.
Operational Playbook (Now)
- Design permissions for new features (CRUD + specific actions). Naming:
domain:resource:action
. - Backend: Add constants/types; enforce at routes/services via
requirePermission
. - Frontend: Use the same catalog for route/component guards.
- IdP config (Clerk/Keycloak): Add permissions; bundle them into roles for assignment convenience.
- Test: Unit-test guards; e2e positive/negative paths.
- Document: Release notes: new permissions + default role bundles.
Guardrails Until IaC
- CI drift check: Export IdP permissions and compare to your code catalog; fail on mismatch.
- No-role-literals lint: Grep/lint for
hasRole(
orroles.includes(
in FE/BE. - Token shape contract: Prefer embedding effective permissions. If not, cache server-side with short TTL.
- Env isolation: Separate dev/stage/prod sets; use a promotion checklist.
Path to IaC (Later, without changing runtime)
- Source of truth: The code catalog is authoritative.
- Generate manifests: Produce JSON/YAML from the catalog.
- Sync pipeline: CI/CD “plan/preview → apply” against Clerk/Keycloak.
- Version & deprecate: Support deprecation windows; surface diffs in PRs.
- IdP adapter: Keep “read effective permissions” behind an interface so IdP swaps are zero-touch for business logic.
ABAC & Cross-Boundary Futures
Today’s model may be org-scoped permissions (org:*
). When cross-org/project access appears, attribute-based access control (ABAC) fits naturally: keep permission checks and add attribute assertions (e.g., subject.orgId == resource.orgId
). Still no role branching.
Migration Recipe (If You Already Have Role Checks)
- Introduce the permission catalog (
permissions.ts
). - Wrap existing endpoints/components with permission guards.
- Move role content into IdP permission bundles; stop reading roles in app code.
- Add lint rules to forbid new role checks.
- Add CI drift check between code catalog and IdP export.
- Plan the IaC sync (non-breaking) for later.
Bottom Line
- Enforce on permissions; treat roles as convenient bundles.
- Define once, use everywhere; FE and BE share the same catalog.
- IdP-agnostic by design; swap Clerk ↔ Keycloak with an adapter, not a refactor.
- Ship today with manual mapping; graduate to IaC tomorrow—without rewriting your authorization logic.
Go forth and un-f*ck your authz.