Tags: web-dev concept

Authorisation Models

Date: 2026-08-17


How “may this user do this to this object” gets answered, and where the answer is computed. The model choice matters less than the placement — a perfect policy checked in the wrong layer is bypassed by anyone who calls the layer beneath it.


Authorisation is deciding whether an identified user may perform a given action on a given resource; an authorisation model is the scheme the rules for that are written in.

The models

RBAC — role-basedABAC — attribute-basedReBAC — relationship-based
Decides fromThe user’s roleAttributes of user, object and contextThe graph between user and object
Rule looks likeadmin may delete ordersregion == user.region && amount < user.limituser is editor of a document in a folder they own
FitsStaff tools, admin panelsRegulated, contextual, per-tenant limitsSharing, hierarchies, collaboration
Fails asRole explosion — editor_uk_readonly_promoRules nobody can reason about or testA graph query on the hot path
Answers “who can see X?”EasilyBadly — you must evaluate every userEasily, by construction

Start with RBAC. It covers most systems and everyone understands it. Add attribute conditions to roles as needed — that hybrid is where the majority of real systems land, and it’s a deliberate destination rather than a compromise.

The signal that you’ve outgrown roles is roles being created per customer, per region or per permutation. manager plus a condition on region is one role; manager_uk, manager_de, manager_fr_readonly is the same thing expressed as combinatorics.

Where the check belongs

This is where breaches happen, and the answer is counterintuitive: not in the UI, and not only at the gateway.

UI                    hides buttons.
                      NOT a control. the API is a URL and curl exists
   ↓
API gateway           coarse: is there a valid token, is this route allowed
                      ✓ good for authentication and route-level rules
                      ✗ cannot know that order 1234 belongs to this customer
   ↓
SERVICE / DOMAIN      ← the check that matters
                      knows the user, the object, and the operation
   ↓
DATA ACCESS           the backstop: scope every query by tenant/owner
                      ✓ makes the whole class of bug structurally impossible

The gateway cannot do object-level authorisation because it doesn’t know who owns the object without fetching it — and by the time something fetched it, you’re in the service layer anyway.

The failure this prevents has a name: insecure direct object reference (IDOR) — the endpoint checks you’re logged in, doesn’t check the record is yours, and changing /orders/1234 to /orders/1235 returns someone else’s order. It is consistently among the most common serious web vulnerabilities found in the wild, precisely because the authentication looks fine — Common Vulnerabilities.

✗  const order = await db.orders.find(req.params.id);
   return order;                            // logged in ≠ authorised

✓  const order = await db.orders.find(req.params.id);
   if (order.customerId !== session.customerId) return res.status(404).end();
   return order;

✓✓ const order = await db.orders.findForCustomer(
       req.params.id, session.customerId);   // unscoped query impossible

Return 404, not 403, for objects the user may not see. A 403 confirms the record exists, which leaks the existence of accounts, orders and customers to anyone enumerating IDs.

The third form is the one worth building towards: a data layer where you cannot express an unscoped query. Discipline fails at some point across hundreds of endpoints; construction doesn’t — Multi-Tenancy.

Making it hard to get wrong

  • Deny by default. New endpoints are inaccessible until a rule permits them. If adding an endpoint without a permission makes it public, every forgotten annotation is a vulnerability
  • One place computes decisions. A policy module or engine that every service calls — not a permission check written inline in each controller with slightly different logic
  • Permissions are verbs on objects, roles are bundles of them. order.refund is the permission; support_agent is a bag of permissions. Code checks permissions, never roles — then changing who can refund is configuration, not a deploy
  • Model the whole operation, not the endpoint. “May refund” and “may refund over £500” are different permissions, and the second is where the money is
  • Test authorisation explicitly. For every endpoint: the owner succeeds, a different customer gets 404, an unauthenticated caller gets 401. This is boring, generatable, and catches the entire IDOR class
  • Log every denial. A user hitting many denials is either broken tooling or someone probing — both worth knowing — Observability

The parts people forget

  • Tenant is the outermost check and is separate from what the user may do inside their tenant — Multi-Tenancy
  • Background jobs and internal service calls need an authorisation context too. A job running “as system” with unlimited rights is where privilege escalation hides
  • Bulk endpoints, exports and reports frequently skip the per-object check that the single-record endpoint performs. Exports are the classic gap and the most damaging one
  • GraphQL needs field- and node-level checks, because one query traverses to objects no single resolver authorised — REST GraphQL and RPC
  • Caches must key on the viewer wherever the response varies by permission, or one user’s authorised response is served to another — Caching Strategies, CDN Caching
  • Revocation lag. With stateless tokens carrying claims, a demoted user keeps their old permissions until expiry. Either keep tokens short or check critical permissions live — Authentication Architecture

Where it interacts