Authentication is not authorization
The two words are used interchangeably and they are not the same thing. Authentication answers “who are you?” and ends with a trustworthy principal: a user id, a tenant, and a way to verify the request came from them. Authorization answers “what are you allowed to do?” and runs after authentication, on every request, against a specific target.
Confusing them is the source of some of the most common security bugs on the web. A perfectly implemented login says nothing about whether the logged-in user should be able to read another customer’s invoice. A valid session proves identity; it does not grant anything. Every endpoint still has to decide, explicitly, whether this principal may perform this action on this resource.
This guide is about the second question. It assumes you already have the first one — a session or token that yields a user — and focuses on the model and the checks that turn that user into an allow or a deny. If authentication is still open, read the session auth guide first.
The RBAC model
Role-Based Access Control is the most widely used authorization model because it matches how organisations actually think. There are three concepts:
- Principals are the things that act: users, service accounts, API keys. Each principal belongs to a tenant.
- Roles are named bundles of capabilities:
viewer,editor,admin,billing. - Permissions are the atoms: a single action on a single resource type, written as
posts:updateorbilling:read.
A principal is assigned one or more roles, and each role is mapped to a set of permissions. The effective permission set for a request is the union of all permissions from all of the principal’s roles. An authorization check then reduces to a single set membership test, plus any resource-specific rules.
The elegance is that permissions are stable while roles are fluid. You can add a moderator role, move posts:delete into it, and no handler changes. The rule “who may delete a post” lives in the data, not in a chain of if statements scattered across the codebase.
Users, roles and permissions
The relational shape is four tables and two many-to-many joins. It is worth internalising because almost every RBAC implementation is a variation of it.
CREATE TABLE permissions (
id bigserial PRIMARY KEY,
action text NOT NULL UNIQUE
);
CREATE TABLE roles (
id bigserial PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE role_permissions (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
Seeding this data in a migration is important. Permissions and role mappings are part of your application’s contract, not something an administrator improvises in production. Keep the seed in version control so every environment agrees on what editor means, and treat a change to it with the same care as a schema change.
Loading the effective permissions is a single query:
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1;
Cache the result per request. Loading it once and attaching it to req.user avoids repeating the query for every check, and a short TTL cache keyed by user id keeps the database out of the hot path.
Role hierarchies
Real organisations have levels. A senior role usually does everything a junior role can, plus more. Modelling that by copying every permission into every role is a maintenance trap: change posts:read and you must remember all five roles that include it.
Instead, let roles inherit. Add a parent_role_id or a role_inherits join table, and expand the hierarchy when you build the permission set. A common shape is viewer → editor → admin, where each level adds capabilities.
CREATE TABLE role_inherits (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
parent_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, parent_id)
);
Expansion is a recursive query or, more simply, a precomputed closure table that stores every ancestor pair. The closure table trades a little storage for a trivial, index-friendly lookup, which is usually the right call because permission checks are far more frequent than role edits.
Guard against cycles. A role that inherits from itself, directly or through a chain, will make expansion loop forever. Validate on write: reject any parent that would create a cycle. Keep hierarchies shallow — three or four levels is plenty — because deep trees are hard for humans to reason about and easy to get wrong.
Why permissions beat role strings
The most common RBAC mistake is not building RBAC at all. It is sprinkling role checks through the codebase:
if (req.user.role !== "admin") return res.sendStatus(403);
This looks harmless and it is a policy decision embedded in a handler. It says that only admin may do this, which may have been true when it was written. When the product adds a support role that also needs access, someone has to find every one of these checks and edit them — and they will miss one. The rule is now spread across dozens of files with no single source of truth.
Checking a permission inverts the dependency. The handler asks “may this principal update a post?” and the answer comes from the data:
if (!can(req.user, "update", post)) return res.sendStatus(403);
Now granting support the ability to update posts is a row in role_permissions, not a code change. The policy is reviewable, testable and consistent. The handler describes intent rather than encoding a specific role.
The rule of thumb: roles are for humans, permissions are for code. A UI may say “Admins can manage billing”, but the check underneath should ask for billing:manage.
The authorization pipeline
Every request follows the same sequence, and each stage has exactly one job.
- Authenticate. Resolve the session or token into a principal: a user id, a tenant id and a role list. If this fails, the request is anonymous and protected routes return 401.
- Load roles. Fetch role assignments, usually from the database or a cache populated at login.
- Expand to permissions. Flatten roles, including inherited ones, into a single set of permission strings.
- Check the action on the resource. Call
can(user, action, resource)for the concrete target, after loading it. - Allow or deny. On success, run the handler. On failure, return 403 without side effects.
- Log the decision. Record the principal, action, resource and outcome.
The order matters for two reasons. Authentication must come first because everything else depends on a trustworthy principal. Resource checks must come after loading the resource, because you cannot evaluate ownership on a record you have not fetched.
Failing closed is non-negotiable. If loading roles throws, or the cache is unreachable, the default is deny. An authorization system that returns “allow” on error is worse than no system at all, because it gives false confidence.
Building a can() helper
Centralising the decision in one function is what keeps route guards and resource checks from drifting apart. The signature is small: a principal, an action, and an optional resource.
export type Action = "read" | "create" | "update" | "delete" | "manage";
export function can(
user: Principal,
action: Action,
resource?: Resource
): boolean {
const permission = `${resource?.type ?? "global"}:${action}`;
if (!user.permissions.has(permission)) return false;
if (resource && resource.tenantId !== user.tenantId) return false;
if (resource && action !== "read" && resource.ownerId !== user.id) {
return user.permissions.has(`${resource.type}:manage`);
}
return true;
}
Three rules are encoded here, in order of importance. The permission set is the coarse gate: if no role grants the action, stop. Tenant isolation comes next and is absolute — a principal must never act outside their tenant, regardless of permissions. Finally, writes on a resource require ownership or an explicit manage grant, which is what lets an editor edit their own drafts while an admin edits anything.
The helper is pure. It takes plain data and returns a boolean, with no database calls inside. That makes it trivial to unit test with a matrix of principals, actions and resources, and it means the same function can run in a route guard, a service, a background job or a UI component that decides whether to render a button.
For the UI, expose the same function to the client through an endpoint or a server-rendered permissions object. The client should hide controls the user cannot use, but the server must still enforce every check, because a hidden button is not a security control.
Enforcing at the route level
A route guard is the first line: it decides whether this kind of action is available to this principal at all. It runs before the handler and before any database work, which makes it a cheap way to reject obvious denials.
export function requirePermission(
action: Action,
type: string
): RequestHandler {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "unauthorized" });
if (!can(req.user, action, { type, ownerId: req.user.id, tenantId: req.user.tenantId })) {
return res.status(403).json({ error: "forbidden" });
}
next();
};
}
Mount it on the router so the rule is visible where the routes are defined:
router.get("/posts", requirePermission("read", "post"), listPosts);
router.post("/posts", requirePermission("create", "post"), createPost);
Returning 401 for a missing principal and 403 for a denied one matters. 401 means “I do not know who you are”; 403 means “I know who you are and you may not do this”. Clients and monitoring treat them differently, and conflating them makes debugging harder.
Route guards are necessary but not sufficient. They answer “may this principal update posts in general?”, not “may they update post 42?”. That second question requires the resource.
Enforcing at the resource level
The resource-level check is where most real vulnerabilities are found, because it is the one people forget. An endpoint like PATCH /posts/:id receives an id from the client. If it trusts that id without checking ownership, any authenticated user can modify any post by guessing or enumerating ids. This is Insecure Direct Object Reference, or IDOR.
The fix is always the same shape: load the resource, then check it.
router.patch("/posts/:id", requireAuth(), async (req, res) => {
const post = await db.post.findById(req.params.id);
if (!post) return res.status(404).json({ error: "not_found" });
const allowed = can(req.user!, "update", {
type: "post",
ownerId: post.authorId,
tenantId: post.tenantId,
});
if (!allowed) return res.status(403).json({ error: "forbidden" });
const updated = await db.post.update(post.id, req.body);
res.json(updated);
});
There is a subtle ordering choice for cross-tenant resources. If a user in tenant A requests a post in tenant B, returning 403 confirms that the post exists, which leaks information across tenants. Many systems return 404 in that case so the resource is indistinguishable from one that does not exist. Whatever you choose, be consistent and document it.
The same pattern applies to nested resources. Before acting on /teams/:teamId/projects/:projectId, verify that the principal can access the team and that the project belongs to it. Every id in the path is attacker-controlled and must be checked.
The permissions matrix
The permissions matrix is a table with roles as rows and permissions as columns, filled with grants. It is the artefact that makes an authorization system reviewable.
posts:read posts:create posts:update posts:delete billing:read
viewer x
editor x x x
admin x x x x x
billing x x
Keep it in version control next to the code, and generate the seed migrations from it so the documentation and the data cannot diverge. When someone proposes a new role, the first question is which columns it gets — and the answer is a diff to this table, not a hunt through handlers.
Two habits make the matrix useful. First, name permissions consistently as resource:action, so the table reads cleanly and the strings are predictable. Second, review the matrix whenever a role changes, because a single extra column is easy to miss in a migration and can grant far more than intended.
Multi-tenant roles
In a multi-tenant application, the same person may hold different roles in different organisations. An agency owner is an admin of their own tenant and a viewer in a client’s. A single global role column cannot express that.
The fix is to scope role assignments by tenant. Add tenant_id to user_roles and make it part of the primary key, so a user can hold distinct roles per tenant. When you build the permission set for a request, you build it for one tenant — the one the request is acting within.
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND ur.tenant_id = $2;
The tenant must come from a trustworthy source: the session, a subdomain you control, or the token. Never accept it from a request body or query string without verifying that the principal belongs to it. Once established, tenant isolation is the first rule in can() and applies before any permission is considered, so no grant can ever cross the boundary.
Switching tenants is a privilege change. If the active tenant lives in the session, update it server-side and regenerate any cached permission set so the old tenant’s grants cannot leak into the new context.
ABAC and policy engines
RBAC answers most questions, but some rules depend on more than role and ownership: time of day, the sensitivity of the data, the user’s department, the request’s risk score. These are attribute-based rules, and trying to encode them as roles produces a combinatorial explosion.
ABAC (Attribute-Based Access Control) evaluates policies against attributes of the principal, the resource, the action and the environment. A rule might read: “a user may read a document if their department matches the document’s department and the classification is not secret”. That is expressible, testable and auditable in a way a role matrix is not.
Policy engines make this practical. Open Policy Agent evaluates policies written in Rego and can be queried as a sidecar or a library, so the same rules apply across services and languages. Casbin offers a lighter model-and-adapter approach with support for RBAC, ABAC and combinations, and is popular in application code.
Adopt a policy engine when the rules genuinely outgrow RBAC, not before. It adds a new language, a deployment surface and a learning curve. A well-factored can() helper with clear rules handles a surprising amount, and you can always wrap it around a policy engine later when a specific decision needs more context.
Testing authorization
Authorization bugs are security bugs, so tests should treat the deny cases as first-class. For every protected action, write a matrix of tests: an anonymous caller, a principal without the permission, an owner, a non-owner with the permission, and a principal from another tenant.
describe("PATCH /posts/:id", () => {
it("rejects anonymous users", async () => {
await request(app).patch("/posts/1").send({ title: "x" }).expect(401);
});
it("rejects users without posts:update", async () => {
await request(app).patch("/posts/1").set("Cookie", viewerCookie).expect(403);
});
it("allows the owner", async () => {
await request(app).patch("/posts/1").set("Cookie", ownerCookie).expect(200);
});
it("rejects a non-owner editor", async () => {
await request(app).patch("/posts/1").set("Cookie", editorCookie).expect(403);
});
it("rejects a user from another tenant", async () => {
await request(app).patch("/posts/1").set("Cookie", otherTenantCookie).expect(404);
});
});
Test can() directly as a pure function, with a table of principals, actions and resources. That covers the logic exhaustively and cheaply, while the endpoint tests prove the check is actually wired in. A common failure is a correct helper that a handler forgot to call, and only an integration test catches that.
Seeding permissions as migrations
Permissions and role mappings are part of your application’s contract, so they belong in migrations, not in an admin panel that drifts between environments.
Write a seed migration that upserts permissions by name and then reconciles each role’s grants to the matrix. Upserts keep the migration idempotent, which matters because it may run against databases that already have some rows.
INSERT INTO permissions (action) VALUES
('posts:read'), ('posts:create'), ('posts:update'), ('posts:delete'),
('billing:read'), ('billing:manage')
ON CONFLICT (action) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.action IN ('posts:read', 'posts:create', 'posts:update')
WHERE r.name = 'editor'
ON CONFLICT DO NOTHING;
Deleting a permission is riskier than adding one. Check for usages in code and, if it is referenced anywhere, rename or retire it gradually. A migration that drops posts:update while a handler still checks it turns every request into a deny, which is safe but confusing until someone reads the seed diff.
Caching the permission set
A permission check should never hit the database. Build the effective set once per request, attach it to the principal, and reuse it for every check in that request.
For high-traffic systems, cache the set per user for a short time, keyed by user and tenant. A 30 to 60 second TTL is usually enough to remove the query from the hot path while keeping role changes visible quickly. When a role changes, invalidate the cache explicitly rather than waiting for the TTL, so a revoked permission stops working immediately.
async function permissionsFor(userId: string, tenantId: string) {
const key = `perm:${tenantId}:${userId}`;
const cached = await redis.get(key);
if (cached) return new Set(JSON.parse(cached));
const rows = await db.query(permissionQuery, [userId, tenantId]);
const set = new Set(rows.map((r) => r.action));
await redis.set(key, JSON.stringify([...set]), "EX", 60);
return set;
}
There is a security trade-off in the TTL. The longer the cache, the larger the window in which a revoked role still works. Prefer explicit invalidation on every role assignment change, and keep the TTL short as a backstop for missed invalidations.
Best practices
- Check permissions, not role names, in application code; keep roles as bundles for humans.
- Centralise the decision in one pure
can(user, action, resource)function. - Deny by default and fail closed if roles or permissions cannot be loaded.
- Enforce tenant isolation before any permission check, and take the tenant from a trusted source.
- Load the resource before authorising an action on it, to prevent IDOR.
- Return 401 for unauthenticated requests and 403 for denied ones.
- Cache the effective permission set per request and invalidate it when roles change.
- Keep the permissions matrix in version control and generate seeds from it.
- Test the negative cases: anonymous, wrong permission, non-owner, other tenant.
- Log allow and deny decisions with enough context to explain them later.
Common mistakes
- Treating a valid session or token as proof of authorization.
- Branching on
user.role === "admin"throughout the codebase. - Checking the route but never the resource, leaving an IDOR hole.
- Trusting a tenant id from the request body or query string.
- Granting broad
managepermissions to avoid modelling a real rule. - Building deep role hierarchies that nobody can reason about.
- Caching permissions forever and leaving stale grants after a role change.
- Returning 403 when 404 would avoid leaking the existence of another tenant’s resource.
- Letting the UI’s hidden buttons stand in for server-side enforcement.
- Forgetting to check every id in a nested route path.
Where to go next
RBAC is the authorization model you will reach for most often, and it composes with everything else you have built. If your principals arrive as tokens, the JWT guide shows where claims like roles fit and why you should still verify them server-side. The principal itself comes from session authentication or, for machines, API keys. And because authorization is always about a target, the REST guide is the right companion for modelling resources and their ids. When your rules start depending on context rather than roles, come back to the ABAC section and reach for a policy engine.