Once you know who the user is, decide what they're allowed to do. The most-broken layer in most apps — and the one with the highest blast radius when it fails.
← Back to Server Sideread, delete, refund).Users are assigned roles (admin, editor, viewer); roles have permissions. Simple, well-understood, and the right starting point for most apps.
Breaks down when permissions need to depend on which resource — "editor of Project A but viewer of Project B." That's where you graduate to ABAC or ReBAC.
Decisions consider attributes of the subject, resource, action, and environment: user.department == doc.department AND time < 18:00. Expressive but easy to make incomprehensible. Pairs well with a policy engine.
Permissions follow graph relationships: "user is a member of org that owns folder that contains this doc." Pioneered by Google's Zanzibar; productized by SpiceDB, OpenFGA, Permify, Warrant. The right tool when sharing & nesting are first-class.
OAuth scopes describe what an API client is allowed to ask for (read:invoices). App-level permissions describe what the user behind that token is allowed to do. You need both: a scope-limited token from a billing assistant should never escalate beyond what the human user already has.
The single most common authorization bug in B2B SaaS: leaking data across tenants because a query forgot the tenant_id filter. Mitigations:
| Layer | What it catches | What it misses |
|---|---|---|
| API Gateway / Middleware | Coarse rules: "must be logged in," "must have admin scope." | Per-resource ownership ("is this your invoice?"). |
| Controller / Handler | Action-level checks before business logic runs. | Bulk operations that touch many resources. |
| Service / Domain | Invariants like "only the assignee can close this ticket." | Read paths if you check only on writes. |
| Database (RLS, views) | The catch-all — even buggy queries can't escape. | Performance overhead; harder to debug. |
Defense in depth: enforce at multiple layers. A bug at one layer shouldn't be a breach.
GET /invoices/42 works for any user who guesses the ID. Always check ownership, not just authentication.GET /invoices happily returns every invoice in the table because the WHERE clause was dropped.