← All writing
Essay

Authorization is a data problem, not a middleware problem

Why access control checks scattered through route handlers always rot, and what it takes to make authorization a first-class, queryable part of your system.

May 2, 20262 min readBackendSecurityAPI Design

Every backend I've inherited had the same disease: authorization logic smeared across route handlers, ORM hooks, and the occasional frontend if statement praying nobody opens devtools. It works on day one. It rots by month six.

The root cause isn't sloppiness. It's that we model authorization as a control-flow concern — "check this before running the handler" — when it's actually a data concern: "which rows is this subject allowed to act on, and why?"

The middleware trap

Middleware-based authorization answers exactly one question: may this request proceed? That's sufficient for DELETE /posts/42 and useless for GET /posts. List endpoints need authorization as a filter, not a gate — and the moment your check lives in imperative middleware, it can't be pushed into the query. So teams fetch everything and filter in memory, or worse, duplicate the logic in SQL by hand. Now there are two implementations of the truth, and they will drift.

The second failure is auditability. When access is denied by an if statement buried in a handler, "why can't this user see this document?" becomes an archaeology project. Policy engines that return decisions with reasons — this rule, this relation, this attribute — turn that into a log line.

What good looks like

The systems that age well share three properties:

  1. Policies are data. Declared once, as pure functions or rules over (subject, action, resource, context) — not scattered through the request pipeline. Pure policies are unit-testable without spinning up an HTTP server, and they run identically in handlers, background jobs, and migrations.
  2. Decisions compile to predicates. The same policy that answers can(user, "read", doc) must also produce the WHERE clause for listReadable(user). If your authorization layer can't do this, your list endpoints are lying about their complexity.
  3. Deny is the default, and denials explain themselves. Every deny carries the rule chain that produced it. This is the difference between a security posture and a security vibe.

The composition reality

Real products never need just one model. You start with roles (RBAC), immediately need ownership (ReBAC — "the author can edit their own draft"), then someone adds tenancy and archival rules (ABAC — "same org, not archived"). The question isn't which model to pick; it's whether your policy layer lets them compose without turning into a decision swamp.

That's the standard I now hold any authorization layer to — including the one I built. Checks you can type-check, filters you can push to the database, and denials that explain themselves. Anything less is middleware theater.