Authentication vs Authorization
Authentication answers "who are you?"; authorization answers "what are you allowed to do?" — and the overwhelming majority of real access-control bugs are systems that did the first one correctly and skipped the second.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Two different questions with two different answers
Authentication establishes identity: this request belongs to Alice, because she presented a credential only Alice should have. It happens once per session, produces a principal, and is a solved problem in the sense that you should use a library or a provider rather than building it.
Authorization decides permission: may Alice read invoice 101? It happens on *every* request, for *every* resource, and cannot be delegated to a library, because only your code knows that invoice 101 belongs to organisation B and that Alice is in organisation A.
The two are routinely conflated because both live "in the auth middleware", and because a successful login feels like the hard part. But consider what the middleware actually knows: it knows the session is valid and that the principal is Alice. It does not know which invoice is being requested, who owns it, or what Alice's relationship to it is. That knowledge exists only after the resource is loaded — which is why authorization has to happen there, and why moving it earlier for tidiness reintroduces the bug.
The example that makes the distinction concrete
Alice logs in. Her session is valid, her password was correct, her MFA challenge succeeded. Every authentication control in the system worked perfectly. She then requests GET /users/bob/private-data.
If the system returns Bob's data, nothing about authentication failed. The identity was established correctly and the request was genuinely from Alice. What failed is that no code asked whether the authenticated principal was entitled to *this* resource. This class of bug — Broken Access Control (IDOR / BOLA) — is consistently the most common serious vulnerability in web applications, and it is invisible to every tool that checks whether authentication is present.
The confusion has practical consequences in how teams respond to it. A team that thinks of this as an "auth bug" reaches for the authentication layer: stronger tokens, shorter sessions, more MFA. None of those change anything, because the attacker is a legitimately authenticated user. The fix is always the same shape: load the resource, compare its owner to the principal, and refuse otherwise.
1app.get('/users/:id/private-data', requireLogin, async (req, res) => {2 // requireLogin proved *someone* is logged in. It proved nothing about :id.3 const data = await db.privateData(req.params.id)4 res.json(data)5})1app.get('/users/:id/private-data', requireLogin, async (req, res) => {2 const target = UserId.parse(req.params.id)3 4 // Self-access, or an explicit relationship. Never "is logged in".5 const allowed = target === req.principal.userId || (await canView(req.principal, target))6 if (!allowed) return res.sendStatus(404) // 404 so the id space is not an oracle7 8 res.json(await db.privateData(target))9})The vulnerable handler treats "has a session" as "may read this". The hardened one makes the ownership relation explicit and, importantly, returns 404 rather than 403 so that an attacker cannot use the difference between the two to enumerate which user ids exist.
Where each belongs in the architecture
Authentication is centralisable and should be centralised: one place that validates the session or token, populates a principal, and rejects everything else. Putting it in middleware, a gateway, or a service mesh is correct, because the decision needs no knowledge of the resource.
Authorization has two halves that live in different places. Coarse authorization — does this principal have the admin role, is this endpoint available to this plan tier — can happen at the route, because it depends only on the principal and the endpoint. Fine-grained authorization — does this principal own this record, is this record in this tenant — must happen where the resource is loaded, because that is the first moment all three of principal, action and resource exist.
The strongest structural answer is to make fine-grained authorization impossible to forget: repository methods that require a principal and scope every query by tenant, so an unscoped query does not compile. That converts a discipline problem into a type problem, which is the only reliable way to hold a rule across a large codebase over years — see Where Authorization Must Live.
| Authentication | Authorization | |
|---|---|---|
| Question | Who are you? | What may you do to this? |
| Frequency | Once per session | Every request, every resource |
| Inputs | Credential, device, second factor | Principal, action, resource, tenant, context |
| Where enforced | Gateway or middleware | Where the resource is loaded |
| Buy or build | Use a provider or library | Yours — it encodes your domain |
| Failure looks like | Login bypass, stolen session | Reading another customer's record |
| Correct status on failure | 401 Unauthorized | 403 Forbidden, or 404 to avoid enumeration |
Key points
- Authentication is who; authorization is what-may-you-do. Conflating them is the root of the most common serious web vulnerability.
- Authentication happens once and is centralisable; authorization happens per request per resource and cannot be.
- Fine-grained authorization is only decidable after the resource is loaded, because that is when owner and tenant are known.
- Stronger authentication does not fix an authorization bug — the attacker is a legitimate authenticated user.
401means "I do not know who you are";403means "I know, and no". Prefer404where existence itself is sensitive.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → sign up: obtain a real, valid account through the normal front door.
- 2Account → observe: note the resource identifiers in their own requests — invoice ids, user ids, file keys.
- 3Identifiers → substitute: change one to a neighbouring value and replay the request with their own valid session.
- 4Response → confirm: a 200 with someone else's data means authorization was never asked; iterate across the id space.
- Every record of the same type becomes readable by any account holder, bounded only by how fast they can iterate.
- The requests are indistinguishable from legitimate traffic in every log that records only status and identity.
- Because the identity is real, there is no credential to revoke — remediation is a code fix plus a full audit of what was read.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Authorize against the loaded resource, comparing its owner or tenant to the authenticated principal.
- • Make unscoped data access structurally impossible: repositories that require a principal, queries that always carry a tenant predicate.
- • Use unguessable identifiers (UUIDv4, or opaque per-user aliases) so that iteration is not trivial — as depth, never as the control.
- • Add a test per resource type asserting that user A cannot read user B's record; this is the single highest-value security test in most codebases.
- • Alert on a single principal accessing many distinct resource ids in a short window, especially sequential ones.
- • Log the resource owner alongside the requester and alert when they differ without an explicit sharing relationship.
- • Watch 403/404 rates per principal; a scan produces a distinctive ramp before it finds a working path.
- • Determine from access logs exactly which records the account read, and treat all of them as disclosed.
- • Fix the endpoint, then audit every other endpoint of the same shape — this bug class is never present exactly once.
- • Preserve the access logs before rotating anything; they are the only way to bound the disclosure.
- • Sharing and delegation features legitimately allow cross-user access and are where the rules get subtle and wrong.
- • Support and admin tooling deliberately bypasses ownership checks, which moves the risk rather than removing it.
- • A correct check on every current endpoint says nothing about the endpoint merged next week.