Authorization: Roles, Policies and Ownership
A new hire ships an orders page. It works in the demo, it passes review, it goes out on a Thursday. You open /orders/8821 and there is your order: line items, shipping address, the last four of the card. Then someone bored types 8822 into the URL bar. Now they are looking at a stranger’s address and a stranger’s card. Nothing crashed. No alarm fired. The 200 was returned in four milliseconds and the logs look completely normal.
The code did exactly one thing wrong. It checked that you were logged in. It never checked that the order was yours.
That gap is the most common serious bug in web APIs, it has a name, and by the end of this article you will reach for the fix by reflex. Let’s start by being precise about the two questions that keep getting confused.
Two questions, two gates
Signing a user in answers one question: who are you? Authorization answers a completely different one: now that I know who you are, may you do this particular thing to this particular object?
They are separate gates, and they fail in separate ways.
Here is the thing worth burning in: authentication is a solved problem. You use a library, you store a session, you are done. Authorization is not solved, because it is your business rules, and no library knows that a refund can only be issued by the finance team for orders in their own region. The custom logic is exactly where the mistakes hide.
The bug with a name: IDOR / BOLA
The stranger’s-order bug is called an Insecure Direct Object Reference (IDOR). The API security world calls the same thing Broken Object Level Authorization (BOLA), and it has sat at number one on the OWASP API Security Top 10 since the list began, holding the top spot again in the current 2023 edition. Industry write-ups put it behind something like 40% of real API attacks. It is not exotic. It is a missing WHERE clause.
The shape is always the same. An endpoint takes an id from the user (/orders/:id, /documents/:id, ?invoice=88), the code fetches that object by id, and it returns it to whoever is logged in. The attacker does not need to break your login. They have a valid login, their own. They just change the number.
Look at the code that causes it, because it looks completely reasonable:
// The bug. Authenticated, but not authorized.
app.get("/orders/:id", requireLogin, async (req, res) => {
const order = await db.query(
"SELECT * FROM orders WHERE id = $1",
[req.params.id],
);
if (!order) return res.sendStatus(404);
res.json(order); // whose order? nobody asked.
});
requireLogin ran. The user is authenticated. And then the query fetches any order by id, with no reference at all to who is asking. Every logged-in user can read every order in the table by counting.
The fix is one clause, in the right place
You might reach for a check after the fetch:
const order = await db.query("SELECT * FROM orders WHERE id = $1", [req.params.id]);
if (order.userId !== req.user.id) return res.sendStatus(403); // better, but fragile
That closes the hole. It is still the weaker fix, and here is why. The check is a separate step that a human has to remember to write. Six months from now someone adds PATCH /orders/:id in a hurry, copies the fetch, and forgets to copy the if. The safety lives in a line that is easy to leave out, and nothing fails loudly when you do.
Put the ownership into the query instead. This is the single most useful sentence in this whole article:
const order = await db.query(
"SELECT * FROM orders WHERE id = $1 AND user_id = $2",
[req.params.id, req.user.id],
);
if (!order) return res.sendStatus(404);
res.json(order);
Now there is no separate check to forget. If the order is not yours, the query matches zero rows, and the natural, already-there if (!order) returns a 404. The authorization is the data access. You cannot fetch the object into a variable and then leak it, because it never comes back in the first place.
Try it. Below is a tiny order lookup with a toggle for the AND user_id = ? clause. You are logged in as Amina, user 42. Ask for your own order and for one that is not yours, with the scope on and off, and watch the private record either leak or 404.
How authorization grows up
Ownership checks handle “is this mine”. Real apps need more than that, and the way the rules get expressed tends to evolve through the same four stages. Knowing where you are on this ladder tells you whether you are about to have a good week or a bad one.
Stage 1: scattered inline checks
You start with if statements next to the code that needs them:
if (post.authorId !== req.user.id) return res.sendStatus(403);
// ...later, elsewhere...
if (req.user.email !== "[email protected]") { /* nope */ }
This is fine for a small app with a couple of rules. It stops being fine the moment the rules multiply, because the logic is smeared across dozens of handlers with no single place to read “what can an editor actually do”. Someone will get one of them subtly wrong, and you will not find out from the code.
Stage 2: RBAC (roles to permissions)
Role-Based Access Control groups permissions into named roles and assigns roles to users. Instead of checking a person, you check a role.
const permissions = {
viewer: ["post:read"],
editor: ["post:read", "post:write"],
admin: ["post:read", "post:write", "post:delete", "user:manage"],
};
function can(user, permission) {
return (permissions[user.role] ?? []).includes(permission);
}
// in a handler:
if (!can(req.user, "post:delete")) return res.sendStatus(403);
RBAC is the right first structure and it covers a huge amount of ground. It answers “what kind of user is this” cleanly, it is easy to audit (there are five roles, here is what each can do), and non-engineers can reason about it.
Where it strains: RBAC talks about kinds of objects, not specific ones. The editor role says “may edit posts”, not “may edit this post”. So RBAC alone cannot express ownership, and you end up bolting an AND user_id = $2 back on anyway. Then the real world asks for “editors, but only in their own region, except contractors, who also cannot touch billing”, and you start minting roles like editor_us_noninvoice. That is role explosion, and it is the sound of RBAC hitting its ceiling.
Stage 3: ABAC (attributes and context)
Attribute-Based Access Control makes the decision from attributes: of the user, of the resource, of the action, and of the environment (time, IP, request risk). Rather than a fixed role list, you write a policy that reads those attributes at request time.
// "An editor may edit a document in their own department, during work hours."
function canEdit(user, doc, now) {
return (
user.role === "editor" &&
user.department === doc.department &&
now.getUTCHours() >= 9 && now.getUTCHours() < 17
);
}
ABAC is far more expressive. Context like “same department” or “business hours” or “only from a managed device” drops right in. The cost is that decisions get harder to predict and harder to audit. Ask “who can edit document 42?” under RBAC and you list the roles. Ask it under ABAC and the honest answer is “run the policy against every user and see”, because the decision depends on attributes that change. Policy engines (Open Policy Agent and friends) exist precisely to keep this from turning into spaghetti.
Stage 4: ReBAC (relationships)
Some questions are really about a graph. “Can Bob edit this doc?” might depend on Bob being in a group that owns the folder the doc lives in. That is not a role and it is not a flat attribute. It is a chain of relationships, possibly several hops long.
Relationship-Based Access Control answers access by walking that chain. It is the model behind Google’s internal authorization system, Zanzibar, which stores billions of object, relation, user tuples and answers permission checks across them at enormous scale. You do not need Google’s scale to use the idea. Open-source implementations bring it within reach: OpenFGA (a CNCF incubating project, originally from the Auth0 / Okta team) and SpiceDB (from AuthZed, the closest faithful reimplementation, running on Postgres or CockroachDB) are the two most common as of mid-2026.
Where to enforce: one layer, not seven
A question that trips up new teams: at which layer does the authorization check belong? UI? API gateway? The service? The database? The tempting answer is “all of them, defense in depth”. The correct answer is that there is exactly one layer that is the source of truth, and the others are convenience or backstop, never the check you rely on.
The layer that must never be the check is the UI.
Hiding a delete button from users who cannot delete is good UX. It is not security, not even slightly, because the attacker never loads your JavaScript. They open a terminal and send the request straight to the API. Anything the browser was “protecting” is gone. If a rule is not enforced on the server, it is not enforced.
So the check lives in your service or data-access layer, on every request, close to where the data is read and written. Not in seven places (which drift out of sync and give a false sense of coverage) and not zero. One authoritative place, plus the database as a genuine backstop, which we will get to.
Fail closed
There is a default that matters more than any single rule: when the authorization logic cannot produce a clear “yes”, the answer must be no.
function authorize(user, action, resource) {
try {
const decision = policy.evaluate(user, action, resource);
return decision === "allow"; // only an explicit allow passes
} catch (err) {
log.error("authz failed", err);
return false; // error means deny, never allow
}
}
This is fail closed, and it is the opposite of what buggy code does naturally. A missing role, a typo, a policy service that timed out, an exception halfway through: every one of those should end in a denied request, not an open door. The dangerous pattern is code that treats “I could not figure it out” as “sure, go ahead”.
Multi-tenancy is authorization wearing a different hat
If you run a B2B app, every customer company is a tenant, and the biggest authorization decision is one you make on literally every query: which tenant’s rows may this request see? A missing tenant filter is not a small bug. It is a cross-tenant leak, the same BOLA from the start of this article, except now one company is reading another company’s entire dataset.
The rule is the same as ownership, one level up. Scope every query by tenant, from the authenticated session, never from user input:
// tenantId comes from the verified session, NOT from the request body or a header
const invoices = await db.query(
"SELECT * FROM invoices WHERE tenant_id = $1 AND id = $2",
[req.session.tenantId, req.params.id],
);
The failure mode here is a single forgotten WHERE tenant_id. In a codebase with hundreds of queries, betting that every developer remembers it on every query forever is a bad bet. This is where a database-enforced backstop earns its keep, and it is covered in more depth in multi-tenancy.
Row-Level Security as a backstop
Postgres can enforce the tenant scope itself, underneath your application, with Row-Level Security (RLS). You attach a policy to a table, and the database silently filters every query to rows the policy permits, even if the application forgot its WHERE clause.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
Then, per request, you set the tenant for the connection inside the transaction and let every query inherit it:
-- at the start of the request's transaction; parameterised, transaction-scoped
SELECT set_config('app.tenant_id', $1, true);
Now a bare SELECT * FROM invoices returns only the current tenant’s rows. The application bug degrades from “leaks every tenant” to “returns fewer rows”, which is a category of problem you can survive.
Validation is not authorization
These two get merged in people’s heads, and keeping them apart will save you real bugs. Validating input asks: is this request well-formed? Is id actually a number, is the email shaped like an email, is the amount within range? Authorization asks a question validation never touches: is this well-formed request allowed, from this user, against this object?
A request can be perfectly valid and completely unauthorized. DELETE /users/42 with a syntactically flawless body, from an account that has no business deleting user 42, passes every validator you own. Validation returns 400 Bad Request when the shape is wrong. Authorization returns 403 or 404 when the shape is fine but the actor is not permitted. Doing one is not doing the other, and a green checkmark from your schema library says nothing at all about permissions.
Test the thing nobody tests
Here is a genuinely uncomfortable fact: almost no team writes the test that would have caught the bug from the first paragraph. Everyone tests the happy path. The owner fetches their own order, gets a 200, green tick, ship it. Nobody writes the negative test, where a different user asks for that same order and must be turned away.
Write it. This one test, per protected resource, is the highest-value security test you own:
import { test } from "node:test";
import assert from "node:assert/strict";
test("user B cannot read user A's order", async () => {
const orderId = await createOrder({ owner: userA });
const res = await api.get(`/orders/${orderId}`, { as: userB });
// The whole point: not 200, and no leaked body.
assert.equal(res.status, 404);
assert.equal(res.body.item, undefined);
});
test("a viewer cannot delete a post", async () => {
const postId = await createPost({ owner: editor });
const res = await api.delete(`/posts/${postId}`, { as: viewer });
assert.equal(res.status, 403);
});
The pattern generalises: for every endpoint that touches an object by id, add a test where the wrong actor makes the request and assert it fails. Cross-tenant reads, role escalation, editing someone else’s resource: one test each. It runs in your test suite on every push, and it fails loudly the day someone adds a new endpoint and forgets to scope it. That is the failure you want, in CI, instead of in a breach report.
Authorization is not a library you install. It is a discipline: ownership in the query, one enforcement layer that actually runs on the server, a default of deny, and tests that prove the wrong person gets nothing. Get those four right and you have closed the hole that leaks more data than any other. For where this sits in the wider picture, see security in depth.
Summary
- Authentication is who you are; authorization is what you may do. They are separate gates, and the breaches almost always live at the second one.
- IDOR / BOLA is the number one API vulnerability: an endpoint returns an object by id without checking the caller owns it. The fix is to put ownership in the query (
WHERE id = $1 AND user_id = $2), so a wrong owner gets zero rows, not a separate check you can forget. - Unguessable ids are defense in depth, not authorization. Ids leak; the ownership check is what actually protects the object.
- Escalate models only as needed: inline checks, then RBAC (roles to permissions, but no instance-level ownership and prone to role explosion), then ABAC (attributes and context, harder to audit), then ReBAC (relationship graphs, the Zanzibar idea, via OpenFGA or SpiceDB). Most apps want RBAC plus an ownership scope.
- Enforce in one authoritative layer on the server, never the UI alone. Hiding a button is UX; the attacker calls the API directly.
- Fail closed. Errors, unknown roles, and unmatched rules must all deny.
- Multi-tenant scoping is authorization: filter every query by the session’s tenant, and use Postgres Row-Level Security (with
FORCE ROW LEVEL SECURITY) as a database-enforced backstop. - Validation is not authorization. A valid request can be completely unauthorized.
- Write the negative test: user A requests user B’s object and must be denied. It is the highest-value security test you will ever add.