Authentication tells you who the request is from. Authorization decides what they're allowed to do. It's the layer where most real-world breaches happen — usually because some endpoint forgot to check.
← Back to Security| Model | Decisions based on… | Best for |
|---|---|---|
| RBAC — Role-Based | The user's role(s): admin, editor, viewer. | Most apps. Simple, auditable, easy to reason about. |
| ABAC — Attribute-Based | Attributes of subject, resource, and environment (tenant, time, IP, sensitivity). | Complex enterprise rules ("editors can publish during business hours from corp IPs"). |
| ReBAC — Relationship-Based | Graph relationships ("is this user a member of the team that owns this doc?"). | Sharing-heavy apps — Google Docs, GitHub. Inspired by Google's Zanzibar. |
In practice, mature systems blend all three: RBAC for coarse roles, ABAC for context, ReBAC for sharing.
link:read, link:delete, user:invite.editor = read + create + update.Coarse checks: is the token valid, does it have the required scope, is the rate limit OK. Cheap and fast — keeps obvious garbage out.
"Is this user allowed to call this endpoint at all?" — usually role-based middleware. Easy to centralize, easy to bypass if you skip it.
"Is this user allowed to act on this specific record?" Skipping this is the #1 OWASP issue: Broken Access Control →.
An "editor" role doesn't mean "edit any link" — it means "edit links you own." Always check ownership/relationship at the object level.
Last line of defense. Postgres row-level security (RLS) or per-tenant DB schemas keep the wrong rows from leaving the server even if a query forgets a WHERE owner_id = ?.
GET /api/links/42 returns link 42 to anyone who knows the URL — no ownership check. The attacker just iterates IDs.
Fix: always join on owner_id = current_user_id in the query. Use unguessable IDs (UUID v7, ULID) as defense in depth, never as the only defense.
PATCH /users/me {"role": "admin"} — and the framework happily writes role because it's a column on the model.
Fix: explicit allow-lists of fields per endpoint. Never deserialize directly into the entity.
The main UI checks ownership. The CSV export endpoint, the GraphQL field resolver, the admin-only debug route — all written by different people, all forgot.
Fix: centralize the policy. Have one can(user, action, resource) function and call it everywhere. Add an integration test for every endpoint.
When rules get complex, scattering them through controllers is a maintenance disaster. Move policies into a dedicated layer.
| Tool | What it offers |
|---|---|
| OPA / Rego | General-purpose policy language; runs as a sidecar or library. Defacto standard. |
| Cedar | AWS-developed policy language; designed for ABAC + RBAC blends. |
| OpenFGA / Permify / Authzed (SpiceDB) | Zanzibar-style ReBAC stores. Model permissions as a graph. |
| Casbin / Oso | Embeddable libraries — policies live alongside your code. |
| Postgres RLS | Row-level security in the database itself. Underrated default for multi-tenant apps. |
You don't need a policy engine on day one. You do need a single function the whole codebase calls.
A small policy with three roles, ownership checks, and a rate-limited anonymous quota.
// One function. Call it everywhere.
function can(user, action, link) {
if (action === 'link:read') return true; // public redirects
if (!user) return false; // everything else needs auth
if (user.role === 'admin') return true; // admins can do anything
if (action === 'link:create') return true; // any user can create
if (action === 'link:delete' || action === 'link:update')
return link.owner_id === user.id; // ownership check
return false; // default deny
}
// Usage in the route handler
app.delete('/links/:code', requireAuth, async (req, res) => {
const link = await db.findLinkByCode(req.params.code);
if (!link) return res.sendStatus(404);
if (!can(req.user, 'link:delete', link)) return res.sendStatus(403);
await db.deleteLink(link.id);
res.sendStatus(204);
});
Pair this with a Postgres policy: CREATE POLICY links_owner ON links USING (owner_id = current_setting('app.user_id')::bigint);. Now even a query bug can't leak someone else's links.