Authentication Is Not Authorization
Every engineer can recite the distinction. Authentication is who you are; authorization is what you're allowed to do. It takes about four seconds to explain and everyone nods.
Every engineer can recite the distinction. Authentication is who you are; authorization is what you're allowed to do. It takes about four seconds to explain and everyone nods.
Then you read the code, and the distinction is gone.
Not because anyone forgot the definition. Because in practice the two blur at specific, recurring points, and each blur produces a characteristic bug. The definitions don't help you spot them. The failure patterns do.
The four-second version, and why it isn't enough
Authentication asks: is this really Alice? It's answered once, at the start, by verifying something — a password, a passkey, a signed assertion from an IdP. The output is a claim: this request belongs to Alice.
Authorization asks: may Alice do this? And that question is answered repeatedly, per action, per resource, with different answers depending on context. Alice may read this document, may not delete it, may edit it on Tuesday but not after the review period closes.
The properties that matter:
| Authentication | Authorization | |
|---|---|---|
| Frequency | Once per session | Every action |
| Scope | Global — you are you | Contextual — depends on the resource |
| Changes | Rarely mid-session | Can change any moment |
| Owner | Identity provider | The application, mostly |
That last row is the one that causes the most architectural trouble, and I'll come back to it.
Failure 1: "Authenticated, therefore authorized"
The most common bug in this family, and it hides in plain sight because the code looks correct.
@requires_login
def get_invoice(invoice_id):
return db.invoices.find(invoice_id)
There's an auth check. It passes review. And any logged-in user can read any invoice by changing the ID in the URL.
The endpoint verified authentication and performed no authorization. Alice is definitely Alice — and nothing checked whether this invoice is hers.
This is IDOR (Insecure Direct Object Reference), it sits near the top of every application security list year after year, and it persists because the authentication check creates a genuine feeling of safety. There's a decorator. It says requires_login. The mental checkbox gets ticked.
The fix is a discipline, not a library: every resource fetch takes the actor as a parameter.
def get_invoice(invoice_id, actor):
invoice = db.invoices.find(invoice_id)
require(invoice.owner_id == actor.org_id)
return invoice
Better still, make it structural — scope the query itself so an unauthorized row can't be returned at all, rather than fetching then checking. A filter you can't forget beats a check you can.
Failure 2: Identity claims used as permissions
A token arrives with "department": "finance". The code does:
if token.department == "finance":
allow_access_to_financial_reports()
This works, and it's wrong in a way that surfaces months later.
department is an identity attribute — a fact about Alice, sourced from HR, describing organizational structure. It was never intended as a permission grant. But it's now load-bearing for access control, which means:
- HR reorganizes and renames the department to "Finance & Ops." Access silently breaks for everyone.
- Someone in finance who shouldn't see reports sees them, because the attribute is coarser than the permission.
- A contractor gets tagged into the department for a mailing list and inherits financial access nobody granted them.
- Nobody can answer "who can see financial reports?" without reading application code, because the answer isn't stored anywhere as a grant.
The distinction worth holding: attributes describe; permissions grant. Attributes are perfectly good inputs to an authorization decision — "users in finance may access financial reports" is a fine policy — but the policy has to exist as a policy. Otherwise you've encoded access control into HR's naming conventions and made the HR team unwitting administrators of your security model.
Failure 3: Authorization decided at the wrong time
Permissions in a token are a photograph taken at login. If Alice's admin role is revoked at 14:00 and her token expires at 14:45, she remains an admin for forty-five minutes, as far as every service validating that token is concerned.
Sometimes that's an acceptable, documented window. Often nobody has decided it's a window at all — the bound just happens to be whatever the token lifetime is, chosen for unrelated reasons.
The tension is genuine and doesn't have a free resolution:
- Claims in the token: fast, no lookup, works offline — but stale, and tokens grow with permission count until you're bumping HTTP header limits.
- Live lookups: always current, arbitrarily expressive — but a network call on every request, and that service is now in everyone's critical path.
The workable split for most systems:
flowchart TD
T["In the token:\ntenant, role, group\n(coarse, slow-changing)"] --> D["Authorization decision"]
L["Looked up live:\nthis resource, this action,\nthis moment"] --> D
Coarse and slow-changing goes in the token. Fine-grained and volatile gets checked live. And anything genuinely dangerous — deleting an account, changing billing, granting admin — should re-verify at the moment of the action rather than trusting a claim minted an hour ago.
Failure 4: Putting all of it in the identity provider
This one is subtler because it starts as good practice.
Centralizing authorization is appealing — one place to define policy, consistent enforcement, auditable. So teams push roles into the IdP. Then groups. Then per-resource permissions. Then rules like "may approve expenses under $5,000 unless the submitter reports to them."
At some point the identity provider is making decisions about domain concepts it fundamentally does not understand. It doesn't know what an expense is, what reporting lines mean, or what "under review" implies. It's being fed a synchronized shadow copy of your business data so it can answer questions your application is better positioned to answer.
The boundary I'd draw:
Identity provider owns: who you are, which tenant you belong to, your coarse role, your group membership, whether you've met the authentication assurance requirements. These are stable, cross-application, and genuinely identity concerns.
Application owns: whether this specific user may perform this specific action on this specific resource, given the current state of that resource. These require domain knowledge, and moving them upstream means replicating your domain model into your IdP — where it will drift.
The test: does answering this question require knowing what the resource is? If yes, it belongs where the resource lives.
Failure 5: Authenticating the wrong party
In service-to-service and agent scenarios, "who is this request from" has more than one answer, and conflating them is a privilege escalation.
Service A calls Service B on Alice's behalf. Service B sees a request authenticated as Service A — which typically has broad permissions, because it needs to serve many users. If B authorizes based on A's identity rather than Alice's, then any user who can get A to make a call inherits A's full permissions.
That's the confused deputy problem, and it's increasingly relevant as agents proliferate: an agent with broad access acting on instructions derived from a less-privileged source is a confused deputy by construction.
The requirement is that both identities travel and both get evaluated: the subject (Alice, on whose behalf this happens) and the actor (the service currently doing it). Standard token exchange handles this — sub stays Alice through the chain while actors accumulate — and it means a downstream service can enforce that Alice may do this thing, rather than that Service A may.
What to actually do
Never authorize on authentication alone. A requires_login check is a gate to the building, not to a room.
Pass the actor into every data access. If a function can fetch a resource without knowing who's asking, it can leak it. Prefer scoping the query over checking after the fetch.
Keep attributes and permissions distinct. Use attributes as policy inputs; don't let them silently become the policy. Otherwise your access control is defined by whoever names things in HR.
Decide your staleness window on purpose. How long may a revoked permission remain effective? Say the number out loud. Re-verify at the action for anything dangerous.
Draw the IdP boundary at domain knowledge. If the decision needs to know what the resource is, the application decides.
Carry subject and actor separately wherever one system acts for another, and authorize on the subject.
The reason it keeps happening
The definitions are easy, so the topic feels finished. Everyone knows authentication isn't authorization, which means nobody spends time on it — and the actual failures don't look like confusion about definitions. They look like a missing parameter, a convenient attribute, a token lifetime nobody chose, a centralization that went one step too far.
Authentication is a door with one question and one answer. Authorization is a question asked continuously, in context, about resources the identity layer has never heard of. Building as though the first implies the second is how a decorator that says requires_login ends up guarding an endpoint that leaks every invoice in the database.