Identityauthenticationauthorizationauthnauthzaccess control

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.

▶ Run the labFollow the failure

Frame the problem

Security starts with a concrete asset, attacker capability and trust crossing.

Asset
Every resource whose access depends on the requester being not merely logged in, but entitled — invoices, messages, settings, other tenants' data.
Attacker & capability
A fully legitimate, fully authenticated user of your product. They have an account, they pay for it, and they are changing a number in a URL.
Trust boundary
The boundary between "this request has an identity" and "this identity may perform this action on this resource".
AssetThreatAttack SurfaceTrust BoundaryVulnerabilityExploit PathImpactMitigationDefense in DepthResidual Risk

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.

Where each question can be answered
coarse: rolefine: ownershipRequest + cookieAuthenticate → principalRoute: is Alice allowed *here*?Load invoice 101Authorize: does Alice own it?Respond
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Authenticated, unauthorized
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})
Authenticated, then authorized against the loaded resource
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 oracle
7
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 and authorization side by side
AuthenticationAuthorization
QuestionWho are you?What may you do to this?
FrequencyOnce per sessionEvery request, every resource
InputsCredential, device, second factorPrincipal, action, resource, tenant, context
Where enforcedGateway or middlewareWhere the resource is loaded
Buy or buildUse a provider or libraryYours — it encodes your domain
Failure looks likeLogin bypass, stolen sessionReading another customer's record
Correct status on failure401 Unauthorized403 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.
  • 401 means "I do not know who you are"; 403 means "I know, and no". Prefer 404 where existence itself is sensitive.

Boundary control exercise

This lesson uses the shared boundary-control exercise.

Boundary control check
Untrusted input / identity
Trust boundary
Privileged asset
Prevention may fail silently.

Follow the attack

Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.

  1. 1
    Attacker → sign up: obtain a real, valid account through the normal front door.
  2. 2
    Account → observe: note the resource identifiers in their own requests — invoice ids, user ids, file keys.
  3. 3
    Identifiers → substitute: change one to a neighbouring value and replay the request with their own valid session.
  4. 4
    Response → confirm: a 200 with someone else's data means authorization was never asked; iterate across the id space.
Blast radius
  • 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.

Prevent
  • • 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.
Detect
  • • 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.
Respond & recover
  • • 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.
Residual risk
  • • 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.

Misconceptions

Claim
“The user is logged in, so the request is authorized.”
Reality
Being logged in establishes only that a real account made the request. It says nothing about the relationship between that account and the resource named in the URL.
Claim
“We use OAuth, so authorization is handled.”
Reality
OAuth delegates authorization *to an external application* over an API. It says nothing about whether the end user may read a given row in your database.