Authorization

RBAC

RBAC answers one question per request: is this principal allowed to do this thing to this resource? Roles bundle permissions, and a single can() check keeps the answer consistent everywhere.

intermediate15 min readUpdated Sep 16, 2026
authorize.ts
ts
// authorize.ts
export type Action = "read" | "create" | "update" | "delete" | "manage";

export function can(
  user: Principal,
  action: Action,
  resource?: Resource
): boolean {
  const permission = `${resource?.type ?? "global"}:${action}`;

  // 1. Does any role grant this action at all?
  if (!user.permissions.has(permission)) return false;

  // 2. Tenant isolation: never cross the boundary.
  if (resource && resource.tenantId !== user.tenantId) return false;

  // 3. Writes on a resource require ownership or manage.
  if (resource && action !== "read" && resource.ownerId !== user.id) {
    return user.permissions.has(`${resource.type}:manage`);
  }

  return true;
}
Model
Users to roles to permissions
Core call
can(user, action, resource)
Default
Deny unless explicitly allowed
Storage
Join tables in SQL
Granularity
Route and resource level
Common flaw
IDOR and missing ownership checks
Next step
ABAC and policy engines
Failure status
403 Forbidden

Why it matters

Why RBAC holds up in production

Permissions, not role strings

Grant named permissions such as posts:update and check those. Role names become labels for humans instead of strings scattered through your handlers.

Roles bundle permissions

A role is a reusable set of grants. Adding a moderator means composing existing permissions rather than editing every route.

One place to decide

A single can() helper means the rule for who may do what lives in one file, so route guards and resource checks cannot drift apart.

The big picture

The three layers of a check

Identify the principal, expand their roles into permissions, then test one action against one resource.

Principal

Identify

Authentication proves who is calling. RBAC starts only after the request has a trustworthy user and tenant attached to it.

Roles

Bundle

Roles group permissions into job-shaped sets such as viewer, editor or admin, and users receive one or more roles.

Permissions

Decide

The atomic grant of an action on a resource. Every check reduces to testing one permission against one target.

At a glance

The pieces you will build

Permissions

posts:update, billing:read, users:manage — the atoms of the model.

Roles

Named bundles like admin, editor and viewer that group permissions.

Matrix

A readable grid of roles against permissions, the source of truth for review.

Multi-tenant

A tenant id narrows every grant so one customer cannot see another.

Hierarchy

Senior roles inherit the permissions of junior ones instead of duplicating them.

Audit

Log every allow and deny so you can explain a decision after the fact.

Data model

The schema behind the check

Permissions are the atoms, roles are named bundles, and two join tables connect users to roles and roles to permissions.

Roles, permissions and the joinsPostgreSQL schema
  • userstableThe authenticated principal, with a tenant_id column
  • rolestableA named bundle such as admin, editor or viewer
  • permissionstableA single action on a resource, for example posts:update
  • role_permissionsjoin tableMany-to-many between roles and permissions
  • user_rolesjoin tableMany-to-many between users and roles, scoped by tenant
  • scopetextOptional tenant id that narrows a grant to one customer

Permissions are the atoms, roles are named bundles, and two join tables connect users to roles and roles to permissions.

Flow

From request to allowed

Authentication happens once; authorization runs on every request, in order, and fails closed.

  1. 1

    Authenticate the principal

    Resolve the session or token into a user id, a tenant id and a set of roles.

  2. 2

    Load roles

    Fetch the user's role assignments from the database or a short-lived cache.

  3. 3

    Expand to permissions

    Flatten each role into its permissions, including inherited ones, to build one set.

  4. 4

    Check the action on the resource

    Call can(user, action, resource) for the specific target, not just the route.

  5. 5

    Allow or return 403

    Proceed to the handler when allowed; otherwise respond with 403 and no side effects.

  6. 6

    Log the decision

    Record the principal, action, resource and outcome for auditing and debugging.

The complete guide

RBAC: Everything you need to know

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:update or billing: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.

  1. 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.
  2. Load roles. Fetch role assignments, usually from the database or a cache populated at login.
  3. Expand to permissions. Flatten roles, including inherited ones, into a single set of permission strings.
  4. Check the action on the resource. Call can(user, action, resource) for the concrete target, after loading it.
  5. Allow or deny. On success, run the handler. On failure, return 403 without side effects.
  6. 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 manage permissions 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.

In practice

Schema, helper, guard, ownership

Four files that take a request from an authenticated user to an authorised action.

migrations/rbac.sql
CREATE TABLE roles (
  id   bigserial PRIMARY KEY,
  name text NOT NULL UNIQUE
);

CREATE TABLE permissions (
  id     bigserial PRIMARY KEY,
  action text NOT NULL UNIQUE -- e.g. "posts:update"
);

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,
  tenant_id bigint NOT NULL REFERENCES tenants (id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, role_id, tenant_id)
);

Check permissions, not role names

A role check hard-codes an assumption that only admins may edit. A permission check expresses the rule directly, so new roles work without touching the handler.

Prefer
if (!can(user, "update", post)) {
  return res.status(403).json({ error: "forbidden" });
}
Avoid
if (user.role !== "admin") {
  return res.status(403).json({ error: "forbidden" });
}
// An "editor" role can never be granted
// this action without editing the code.

Authorise the resource, not just the route

A route guard proves the user may edit some post. Only a resource check proves they may edit this post. Skipping it is the classic IDOR vulnerability.

Prefer
const post = await db.post.findById(req.params.id);
if (!post) return res.sendStatus(404);

if (!can(req.user!, "update", {
  type: "post",
  ownerId: post.authorId,
  tenantId: post.tenantId,
})) {
  return res.sendStatus(403);
}
Avoid
router.patch("/posts/:id", requirePermission("update", "post"),
  async (req, res) => {
    // Any user who can update any post
    // can now update every post by id.
    await db.post.update(req.params.id, req.body);
    res.sendStatus(204);
  });

Trade-offs

Is RBAC the right model?

RBAC is the pragmatic default for most applications. Know where it stops being enough.

Strengths

  • Easy to explain and review

    A permissions matrix is something a non-engineer can read. Reviews and audits become a matter of checking a table rather than tracing code paths.

  • Reusable and composable

    Roles package permissions once and apply them everywhere. New features reuse existing atoms instead of inventing new checks.

  • Maps to how teams work

    Job functions such as support, editor and admin align naturally with roles, which makes the model intuitive for the people using it.

Trade-offs

  • Role explosion is real

    Without a disciplined permission vocabulary, teams create a new role for every edge case until nobody can say what any of them mean.

  • Context is hard to express

    Rules like "editors may publish their own drafts during business hours" do not fit roles cleanly. That is when ABAC or a policy engine earns its keep.

  • Caching must be invalidated

    Permissions are usually cached per request or per token. A role change must invalidate that cache or the old grant lingers until expiry.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning RBAC & Permissions?

Our interactive tutorial walks you through RBAC & Permissions step by step — with quizzes and real code you can run in the browser.