AuthzGENERALFRAMEWORK-SPECIFIC

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.

What actually happensHow to build it

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.

The question

Who decides whether this specific request is allowed, and where does that decision actually live?

The requirement

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.

The obvious build

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.

Why it breaks

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".

How it breaks in production
  • 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 approveExpense lets 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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/42 contains the id 42; 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.
who is this?principalid 42 (a claim)resourcePATCH /expenses/42Context state, time, amountAuthentication principalLoad resource expense 42Decision canApprove()Enforcement 403 or proceed
UserLLMAgentToolDataDecisionHumanGuardrail

Default-deny is the only default that fails safely

GENERALDefault-deny is a design property, not a framework feature. Some frameworks (Spring Security, Django REST Framework) ship with a deny-by-default permission class you can set globally; Express and most Go routers have no such notion, so you write the rejecting fallthrough yourself.

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.

The shape of the rule
Deny-list, checked at the route
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
})
Allow-list, checked against the object
// 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.

Ways the check is bypassed without anyone removing it
TriggerSymptomCauseResponse
A GraphQL resolver is added for the same entityData reachable with no 403 anywhere in logsAuthorization lived in REST middleware; the resolver reaches the repository directlyMove the object-level check into the repository or service method every path shares.
A nightly export job is writtenA CSV containing every tenant's rows lands in a shared bucketJobs have no session, so "the current user" is undefined and the scoping silently disappearsMake 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 outBrief window where everything is permittedThe catch block logged and continuedFail closed. Return 503, not 200 (Timeouts).
An async predicate is called without awaitEvery request authorized, tests still green if they only test the happy pathA pending Promise is truthyReturn a typed Decision object rather than a boolean, so a Promise in a boolean position is a type error.
A role is revokedUser keeps their access for minutesPermissions cached in the session or a local cacheDecide 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, tenantId or isAdmin field 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

Failure modes
  • 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 await forgotten on an async predicate returns a Promise, which is truthy, and every caller is authorized. A pending Promise is never false (Await Is a Yield Point).
  • Fail-open on error: the permission lookup throws, the catch logs 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).
What can race
  • 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).
Security
  • 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).
Misreads
  • "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

How you see it in production
  • 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).
What changes at 10x and 100x
  • 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.
What this costs
  • 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.

Domains that do not exist yet
  • 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.