Authorisation

Check, on every request, that this user may do this thing to this object.

06of 16
≈ 13Min read
6Parts

Authentication is who. Authorisation is what

Logging in is the easy half, and the half that gets all the attention. The breaches come from the other one: a logged-in user asking for something that belongs to somebody else, and getting it because nobody checked.

It has a name — insecure direct object reference — and it is the most common serious bug in ordinary web applications. /api/orders/482 works perfectly for the customer who owns order 482. What does it do for the customer who owns 481 and types 482?

The rule: deny by default

With a blocklist, every new route is open until somebody remembers to add it. With an allowlist, every new route is closed until somebody decides who may use it. The second one is the only one that survives a team, a year, and a Friday afternoon.

two shapes, and only one of them fails safely
// a blocklist: everything is allowed unless it is listed
if (BANNED_ROUTES.includes(route) && !user.isAdmin) deny();

// an allowlist: nothing is allowed unless a rule says so
const rule = RULES[`${user.role}:${action}`];
if (!rule || !rule(user, resource)) deny();

Check the object, not just the route

Putting the user's id into the query rather than into an if after it is the version that cannot be forgotten: there is no code path that fetches somebody else's row in the first place. Frameworks with row-level security (Postgres RLS, Supabase policies) push the same idea into the database.

the check that is actually missing
// authenticated, and wrong
app.get("/api/orders/:id", requireLogin, async (req, res) => {
  res.json(await db.order(req.params.id));          // whose order?
});

// the ownership check belongs in the query
app.get("/api/orders/:id", requireLogin, async (req, res) => {
  const order = await db.orderForCustomer(req.params.id, req.user.customerId);

  if (!order) return res.status(404).send("Not found");
  res.json(order);
});
404 or 403?

If the existence of the object is itself information — another customer's order, a private repository — answer 404. A 403 confirms that id 482 exists, which is enough to enumerate your customer base. When the user knows the thing exists and simply may not act on it, 403 is clearer and kinder.

Where the checks go

Most teams end up with two: a coarse check on the route and an ownership filter in the query. The thing to avoid is a check that lives only in the user interface — a hidden button is not a permission.

PlaceGood forFails when
middleware on a routecoarse rules: is this an admin areaa route is added elsewhere
inside the handlerthe specific actionsomebody forgets one
inside the data layerownership, every timea raw query bypasses it
in the database (RLS)everything, unavoidablyyou need a service account for jobs

Roles, and where they stop working

Roles ("admin", "staff", "customer") answer what kind of user is this. They cannot answer "may this fitter see this job" — that is a relationship between a user and an object, and it needs the object. A permission system that only has roles ends up with isAdminOrTheOwnerOrTheAssignedFitterOr… spread across forty handlers.

The shape that scales: a single can(user, action, resource) function, called everywhere, with the rules in one table. It can be tested on its own — which is the lab below.

The ones people forget

  • Mass assignment. Object.assign(user, req.body) lets a customer set role: "admin". Take only the fields you meant: const { name, email } = req.body.
  • The second endpoint. The web route checks ownership and the CSV export does not. Or the GraphQL resolver. Or the admin API that was "internal".
  • Sequential ids. Not a vulnerability by themselves, but they turn one missing check into a complete data export. UUIDs make enumeration expensive, and are not a substitute for the check.
  • Server-side rendering. Hiding a button is presentation; the request behind it still has to be refused.

Practice