Authentication answers who are you? Authorization answers what may you do? The two are frequently conflated in code review and incident post-mortems, and the conflation produces a specific, recurring vulnerability: systems that verify identity carefully and then check permissions sloppily — or not at all. This article defines the boundary, walks through the standard permission models, and explains why authorization is an object-level problem that middleware cannot solve.
The Boundary, Precisely
Authentication establishes an identity claim backed by evidence: password, TOTP code, WebAuthn assertion, session token. The output is a subject — "this session belongs to user 4821." Authorization consumes that subject and answers a different question: "may user 4821 read invoice 9912?" The distinction matters because:
- Authentication failures produce login errors; authorization failures produce 403s.
- Authenticating a user does not grant them anything — it only makes permission decisions possible.
- Authorization without authentication is meaningless (no identity to decide for), and authentication without authorization is a walk-in door: every authenticated user can do everything.
The classic exploit born from the conflation is IDOR (Insecure Direct Object Reference): the app authenticates, then trusts the client to say which object it wants:
GET /api/invoice/9912 # my invoice — fine
GET /api/invoice/9913 # someone else's — 200, data leakedThe server verified the session but never verified the object ownership.
RBAC: Role-Based Access Control
RBAC models permissions as roles: users are assigned roles, roles carry permissions.
roles: admin, editor, viewer
editor → can_edit_posts, can_publish_own_posts
viewer → can_read_postsDecision logic becomes a lookup: "does the subject's role set contain a role with can_edit_posts?" RBAC scales because permission changes happen at the role level, not per-user. Its weakness is granularity: roles are coarse. "Ownership" — I can edit my posts but not yours — is not expressible in pure RBAC without exploding into per-object roles.
ABAC: Attribute-Based Access Control
ABAC makes decisions from attributes of the subject, resource, action, and environment, evaluated against a policy:
allow if subject.role == "editor"
and resource.type == "post"
and (resource.author_id == subject.id
or subject.role == "admin")Policies are declarative — often expressed as a policy language (XACML, OPA/Rego) — which keeps complex business rules out of code. The cost: policy engines add latency, require careful evaluation-order semantics (allow-overrides vs deny-overrides), and are only as correct as their attribute sources. Most systems end up with hybrid RBAC/ABAC: roles for coarse structure, attribute rules for object-level nuance.
Why Middleware Checks Fail
The seductive pattern is a single auth gate in middleware:
app.use("/api/*", requireAuth); // authenticate — correct place
app.use("/api/*", requireRole("admin")); // authorize — wrong placeThe middleware authorizes the route, but real permission decisions are about objects, and the object is not even loaded when middleware runs. Consequences:
- Object-level bypass:
/api/invoice/9913passesrequireRole("viewer")— the role check passes, the object is another user's, and no one re-checks. - Multi-tenant leaks: the same route serves different tenants; tenant scoping is an object property, invisible to middleware.
- Conditional permissions: "own records vs others' records," "drafts vs published," "team members vs the rest" — none of these exist at route granularity.
The working pattern: middleware authenticates and loads the subject once; authorization runs at the resource boundary, where the object is in hand:
def get_invoice(invoice_id, user):
invoice = Invoice.query.get_or_404(invoice_id) # load object
if not user.can("read", invoice): # decide per object
abort(403)
return invoicePolicy engines (e.g., OPA) formalize this: the resource handler passes {subject, action, resource} and gets a verdict — no ad-hoc if chains scattered through handlers.
The Failure Modes Checklist
- Fail closed: an unknown subject, a missing attribute, an unhandled policy case — all must default to deny. Fall-through-to-allow is the most common authorization bug in the wild.
- Default-deny routes: new endpoints must require an explicit decision, not inherit "no check."
- Tenant scoping: every query must be scoped by the authenticated tenant, never by client-supplied IDs alone.
- Re-check at write time: authorization is not a read-time concern — mutations need the same per-object decision, or users can write objects they cannot read.
Verdict
Authentication and authorization are two decisions made at different points in the request lifecycle: identity at the gate, permission at the object. Conflating them produces systems that authenticate beautifully and authorize by route name — which is exactly how IDOR leaks billions of records per year. Model permissions explicitly (RBAC for structure, attribute rules for nuance), evaluate them where the object exists, and default every path to deny.