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.
// 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.
// 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);
});
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.
| Place | Good for | Fails when |
|---|---|---|
| middleware on a route | coarse rules: is this an admin area | a route is added elsewhere |
| inside the handler | the specific action | somebody forgets one |
| inside the data layer | ownership, every time | a raw query bypasses it |
| in the database (RLS) | everything, unavoidably | you 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 setrole: "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.