Security
Lesson 4 of 8About 3 min readSuggest an edit

Authorisation and access control

Authentication establishes who the caller is; authorisation decides what that caller may do. It is easy to get almost right: most endpoints check, one forgets, and that one leaks everything.

Choosing a model

Model Decision based on Fits well when
RBAC the user’s roles a few stable job functions
ABAC attributes of user, resource, context rules like “same region, office hours”
ReBAC relationships between objects sharing, folders, teams, orgs

Role-based access control (RBAC) grants permissions to roles such as admin, editor and viewer, then assigns roles to users. It is simple, but roles multiply as special cases appear, and roles alone cannot express “only your own documents”.

Attribute-based access control (ABAC) evaluates rules over attributes: the user’s department, the resource’s classification, the time or network. It is flexible, but rules can become hard to audit.

Relationship-based access control (ReBAC) answers questions like “is this user an editor of this document, directly or through a team or parent folder?”. OpenFGA and SpiceDB, inspired by Google’s Zanzibar paper, implement it. It suits products with sharing and nested ownership.

Most real systems combine them: roles for coarse permissions, ownership or relationships for individual objects.

Object-level checks

An insecure direct object reference (IDOR), also called broken object level authorisation, happens when the server accepts an id from the client and acts on it without checking that this caller may use that object.

Check the object, not just the route, and check it on every action: read, update, delete, export and any bulk endpoint.

Deny by default and centralise policy

Scattered if (user.isAdmin) checks drift apart and get forgotten. Put decisions in one place and make “no rule matched” mean no.

type Action = "read" | "update" | "delete";

export function can(user: User, action: Action, doc: Doc) {
  if (doc.tenantId !== user.tenantId) return false;
  if (doc.ownerId === user.id) return true;
  if (action === "read" && doc.sharedWith.includes(user.id)) {
    return true;
  }
  return false; // deny by default
}

// in a handler
const doc = await docs.get(req.params.id);
if (!doc || !can(req.user, "update", doc)) {
  return res.sendStatus(404);
}

A single policy module, or a policy engine such as Open Policy Agent or Cedar for larger systems, gives you one place to review, test and log decisions. Make routes fail closed too: a new endpoint without an explicit permission should be blocked, not open.

Enforce on the server, every request

Hidden buttons and client-side route guards improve the experience but provide no security, because the user controls the client. Also watch for:

  • mass assignment: a request body that sets role or ownerId because the handler copied every field onto the record;
  • stale decisions: caching “is admin” in a long-lived token means revoked access keeps working until it expires;
  • indirect paths: file downloads, websockets, GraphQL resolvers and background jobs need the same checks as your REST routes.

Multi-tenant isolation

With many customers on shared infrastructure, one missing WHERE tenant_id = ? exposes another customer’s data. Reduce the chance of that mistake:

  • derive the tenant from the authenticated session, never from a request parameter;
  • scope queries through a repository layer that always adds the tenant filter;
  • consider database row-level security as a second layer;
  • include the tenant in cache keys, storage paths and search indexes.

Testing authorisation

Happy-path tests miss authorisation bugs, because the test user owns everything it touches. Write tests that cross boundaries:

  • user A requests user B’s object and gets 404 or 403;
  • a viewer attempts every write action;
  • a user from tenant 1 lists, searches and exports, and sees nothing from tenant 2.

Checklist

  • Every endpoint maps to an explicit permission; unmapped means denied.
  • Object ownership or relationship is checked, not only the role.
  • Tenant comes from the session and is enforced in the data layer.
  • Policy lives in one module and is covered by cross-user tests.
  • Access decisions for sensitive actions are logged.

Next: Threat modelling

Finding what can go wrong before you build, using data-flow diagrams, trust boundaries and STRIDE.