Authorization in Backends
Every request carries a claim about what the caller may do. The backend is the only place that claim can be tested.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Who decides whether this specific request is allowed, and where does that decision actually live?
Expense reports. An employee may submit their own and read their own. A manager may read and approve reports belonging to people in their team. Finance may read everything and export it. Nobody may approve their own report, including a manager.
Put the check where the feature is. At the top of approveExpense, read user.role, and if it is not manager or finance, return 403. It is one line, it is right next to the code it protects, and it reads clearly.
Six months later there are four ways to reach the same expense record: the REST handler, a GraphQL resolver, an admin endpoint, and a CSV export job. Three of them have the role check. The export does not, because it was written as "internal tooling".
- Six months later there are four ways to reach the same expense record: the REST handler, a GraphQL resolver, an admin endpoint, and a CSV export job. Three of them have the role check. The export does not, because it was written as "internal tooling".
- The role check passes and the object check never happens. A manager is allowed to approve expenses — so
approveExpenselets a manager approve *any* expense, including one from a different department and their own (Object-Level Authorization). - "Nobody may approve their own report" has no home. It is not a role, it is a relationship between the principal and the object, and an
if (role === 'manager')has nowhere to put it. - The rule changes — finance can now approve up to a limit — and you have to find every call site by grep. The ones you miss do not fail loudly; they keep allowing what they always allowed.
- Nobody can answer "who can approve this report and why" without reading source, which makes the rule impossible to review and impossible to test as a unit.
What is actually happening
- An authorization decision is a function of four inputs: principal (who), action (what they are trying to do), resource (to what), and context (when, from where, in what state). Everything in this module is a way of writing that function.
- The principal comes from authentication and nowhere else — the verified session, the validated token's claims, the API key's owner record (Authentication in a Backend). A user id in the body is a claim, not a principal (The Trust Boundary).
- The resource is usually not in the request either.
PATCH /expenses/42contains the id42; the *resource* is the row, and the row has an owner, a team, a state and an amount that the decision depends on. That is why real authorization usually needs a database read before it can decide. - Two roles exist in any authorization system, even an implicit one: the place that decides (evaluates the rule) and the place that enforces (refuses to proceed). Frameworks blur them; keeping them distinct is what makes rules testable in isolation and enforceable in more than one place.
- The default matters more than any individual rule. Default-deny means an unlisted route or a new action is refused; default-allow means every new surface is open until someone remembers it. Only one of those fails safely (Defence in Depth).
Four inputs, one decision, two places
Every authorization system that has ever worked is a function from four inputs to a boolean. The reason authorization feels hard is that three of those inputs are not in the request: the principal comes from authentication, the resource comes from the database, and the context comes from the clock, the environment and the object's own state.
It is worth separating the decision from the enforcement even in a small codebase. The decision is a pure function you can unit test with a made-up user and a made-up expense. The enforcement is the throw in the request path. When they are the same expression buried in a handler, you can test neither in isolation, and you can only enforce in one place.
- Principal — from the verified session or token. Never from the body.
- Action — what is being attempted:
read,approve,export. Coarser than the route. - Resource — the row, not the id. Usually requires a read before you can decide.
- Context — object state, time, request origin, amount thresholds, delegation windows.
Default-deny is the only default that fails safely
The difference between an allow-list and a deny-list shows up in the code you have not written yet. With a deny-list, the endpoint added next sprint is public until someone remembers to protect it; with an allow-list, it is broken until someone remembers to open it. The first failure is silent and the second is a bug report within the hour.
The same asymmetry applies inside a rule. if (user.role === 'contractor') return deny breaks the moment a fifth role exists. if (user.role === 'manager' || user.role === 'finance') return allow; return deny keeps its meaning as roles are added.
app.use((req, res, next) => {
if (PUBLIC_ROUTES.includes(req.path)) return next()
if (!req.session) return res.status(401).end()
if (req.session.role === 'contractor') return res.status(403).end()
next() // everyone else: whatever this route does
})// pure, testable, no framework
export function canApprove(user: Principal, e: Expense): Decision {
if (e.submittedBy === user.id) return deny('self-approval')
if (user.role === 'finance' && e.amount <= user.limit) return allow('finance-limit')
if (user.role === 'manager' && e.teamId === user.teamId) return allow('manager-own-team')
return deny('no-matching-rule')
}The first grants "whatever this route does" to every role that is not explicitly excluded, and cannot express "not your own report" because it never loads the report. The second refuses by default, names the rule that fired so the decision is auditable, and is a function you can test without starting a server.
The endpoint is not the only door
Authorization written as middleware protects requests that pass through middleware. That is not the same set as "operations that touch the data". Over time, admin CLIs, database-backed background jobs, GraphQL resolvers, replays of a queue and internal RPC all reach the same rows on paths that never touched your pipeline.
This is why the durable place for object-level rules is next to the data access, not next to the route. A route check is still worth having — it rejects cheaply and it documents intent — but it is a first gate, not the guarantee.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A GraphQL resolver is added for the same entity | Data reachable with no 403 anywhere in logs | Authorization lived in REST middleware; the resolver reaches the repository directly | Move the object-level check into the repository or service method every path shares. |
| A nightly export job is written | A CSV containing every tenant's rows lands in a shared bucket | Jobs have no session, so "the current user" is undefined and the scoping silently disappears | Make the data access require an explicit principal argument, so a job cannot compile without stating whose data it reads (Tenant Isolation). |
| A permission service call times out | Brief window where everything is permitted | The catch block logged and continued | Fail closed. Return 503, not 200 (Timeouts). |
An async predicate is called without await | Every request authorized, tests still green if they only test the happy path | A pending Promise is truthy | Return a typed Decision object rather than a boolean, so a Promise in a boolean position is a type error. |
| A role is revoked | User keeps their access for minutes | Permissions cached in the session or a local cache | Decide deliberately: short TTL, or a revocation check on high-value actions (Cache Invalidation). |
How to build it
Most important first.
- Write authorization as named functions over (principal, action, resource) —
canApprove(user, expense)— not as inline conditionals. A named function can be unit tested with no HTTP, listed in a review, and called from the resolver, the handler and the job. - Derive the principal from the authenticated context, always. Never accept a
userId,role,tenantIdorisAdminfield from the request and use it in a decision (Tenant Isolation). - Put the check where the object is loaded, not only where the route is declared. A route-level check answers "may this kind of user call this endpoint"; only an object-level check answers "may *this* user touch *this* row" (Object-Level Authorization).
- Make it default-deny: an action with no matching rule is refused. Route-level allow-lists and a rejecting fallthrough beat a deny-list you must keep complete.
- Emit the decision. Log allow and deny with principal, action, resource id and the rule that fired — this is your audit trail and your only debugging surface when someone says "I should be able to see this" (Structured Logging).
- Keep the *policy* out of the handler and the *enforcement* in the data path. A handler should ask a question; it should not be the last thing standing between a caller and a row.
What can go wrong
- A new endpoint reaching an existing service method that assumed its caller had already checked. The service method is now the vulnerability, and it did not change.
- The check runs but its result is ignored — an
awaitforgotten on an async predicate returns a Promise, which is truthy, and every caller is authorized. A pending Promise is neverfalse(Await Is a Yield Point). - Fail-open on error: the permission lookup throws, the
catchlogs and continues, and the request proceeds. Authorization must fail closed (The Error Boundary). - Caching a permission decision and never invalidating it, so a revoked role stays effective until the TTL expires (TTL and Expiry).
- Enforcing only in middleware, then adding a background job or admin CLI that reaches the same data without passing through middleware at all (Background Jobs).
- A permission revoked while a request is in flight: the check passed, then the role was removed, then the write happened. Revocation is eventually consistent with any request already past its check — for high-value actions, re-check inside the transaction (Where the Transaction Boundary Goes).
- Check-then-act on the object: you verify the expense is in state
submitted, then approve it, and a concurrent request approved it in between. The authorization check must be part of the same conditional write, not a preceding read (Optimistic Concurrency).
- If the check is missing entirely, an attacker who can create an ordinary account gets whatever the endpoint does, applied to anyone's data: read every expense report, approve their own, export the entire table. No exploit skill is required — a normal login and a changed id in the URL is the whole attack.
- If the check is present at route level but absent at object level, an attacker gets lateral movement within their own privilege tier: any employee can act on any other employee's objects, which in a customer-facing product means any customer can act on any other customer (Broken Access Control (IDOR / BOLA)).
- If the check reads its inputs from the request, an attacker gets privilege escalation for free — they send
{"role": "admin"}and become an admin, because the rule is evaluated against data the attacker wrote (Mass Assignment and Over-Posting). - If the check fails open on error, an attacker gets an escalation primitive: anything that makes the permission store slow or unavailable becomes an authorization bypass.
- Authorization cannot be enforced anywhere but the server. A disabled button, a hidden menu item and a filtered client-side list are product decisions with zero security value (Where the Check Belongs).
- "We have auth" — meaning there is a login. Authentication is a prerequisite for authorization, not a form of it (Authentication vs Authorization).
- "Roles are authorization." Roles are one input to a decision. Most real rules also involve the object ("their team", "their own", "not yet approved"), which no role can express (Role-Based Access Control).
- "The API gateway handles it." A gateway can check tokens, scopes and routes. It does not know that expense 42 belongs to Dana, so it cannot make an object-level decision.
- "Our clients are our own apps." Your apps are one client. The API is the interface, and it is reachable with curl by anyone holding a valid session.
Operating it
- A counter of authorization decisions labelled by route, action and outcome. A route that has never denied anything since deploy is either genuinely public or not actually checking (The Metrics a Backend Must Emit).
- A deny log line with principal id, action, resource type and id, and the rule name. Without the rule name, "403" is unactionable for both support and security.
- A spike in denials from one principal is enumeration; a spike in denials across many principals right after a deploy is a regression in your rules, not an attack.
- Keep authorization decisions on the request trace as a span or an event so a slow endpoint that turns out to be doing four permission lookups is visible (Tracing From the Backend's Side).
- At 10x traffic, the extra database read that loads the resource before deciding is the cost that shows up first — it doubles queries on read-heavy endpoints. Usually the fix is to fold the check into the query rather than to cache the decision (Object-Level Authorization).
- At 100x, permission data itself gets big: group memberships, project shares, hierarchical teams. A per-request "expand all groups this user belongs to" becomes a real query and is where a purpose-built store or a materialized membership table earns its keep.
- Nothing about the *rules* changes with scale. The rule "a manager may approve for their team" is the same at 10 users and 10 million; only how you evaluate it efficiently changes.
- Centralized, named policy functions add indirection: the rule is no longer visible at the call site, and a reader has to follow one hop to see what is enforced. That is the price of being able to test and review it at all.
- Loading the resource before deciding costs a read on every request, including the ones you are about to reject. Rejecting cheaply and rejecting correctly pull in opposite directions.
- Default-deny will break legitimate features during development, repeatedly. That friction is the mechanism working; teams that find it annoying tend to relax it into default-allow and lose the guarantee.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe (principal, action, resource, context) decomposition and default-deny hold across every stack. What differs is only where you put the enforcement.
- FRAMEWORK-SPECIFICWhere a framework invites you to enforce differs sharply: Rails and Django steer toward per-object policy objects and querysets scoped in the view; Express gives you middleware and nothing object-level; Spring offers method-level annotations evaluated by an expression language. The rule is the same; the place a new engineer will look for it is not.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — authorization rules are the clearest case for property-based tests: generate principals and resources, assert that the deny set is what you meant.