Authentication vs Authorization
"Who are you" and "may you do this" are different questions with different answers, different failure modes and different blast radii.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Why is conflating authentication and authorization the most common serious backend security mistake?
A project management tool. Anyone with an account can log in. Not everyone with an account should see every project — and today, they can.
The endpoint is behind auth. requireLogin runs in middleware, so only logged-in users reach the handler; a logged-in user is a legitimate user, and legitimate users are allowed to use the product.
Every one of your users is a legitimate user, including the one enumerating /projects/1, /projects/2, /projects/3. They all pass requireLogin because they all have accounts.
- Every one of your users is a legitimate user, including the one enumerating
/projects/1,/projects/2,/projects/3. They all passrequireLoginbecause they all have accounts. - Signup is often open. "Authenticated" then means "anyone on the internet who filled in a form", which is a weaker guarantee than most codebases assume it to be.
- The word "auth" in a codebase —
auth.ts,authMiddleware,isAuthed— hides which question is being answered, so a review of "does this endpoint have auth" cannot distinguish a protected endpoint from an exposed one. - A correct and expensive authentication stack — MFA, short-lived tokens, rotation — makes the breach *harder to reach* and does nothing to reduce what an attacker gets once they have any account at all.
What is actually happening
- Authentication establishes identity: it answers "which principal is this request from" by verifying something — a password against a hash, a signature on a token, a session id against a store (Session Authentication, Token Authentication and the Revocation Problem).
- Authorization establishes permission: it answers "may this principal perform this action on this resource". It takes the output of authentication as one of its inputs and can never substitute for it.
- They fail differently. An authentication failure is a 401 and means "prove who you are, then try again". An authorization failure is a 403 and means "we know who you are; the answer is still no" — retrying with the same credential is pointless (Status Codes From the Server's Side).
- They have different blast radii. Broken authentication lets an attacker become someone else. Broken authorization lets every existing user reach everything — which is often the larger number, because you have far more users than attackers.
- They live in different places. Authentication is genuinely a cross-cutting concern and belongs in the pipeline. Authorization depends on the resource, so most of it cannot live in the pipeline at all (Where the Check Belongs).
Two questions, side by side
The clearest way to keep these apart is to notice that they have different answers, different failure responses, different lifetimes and different places in the request path. Almost nothing about one transfers to the other.
| Authentication | Authorization | |
|---|---|---|
| Question | Which principal is this? | May this principal do this to this? |
| Input | A credential: password, token, key, cookie | A principal, an action, a resource, context |
| Answer | An identity, or nothing | Allow or deny, for one specific attempt |
| HTTP failure | 401 — retry with a credential | 403 — retrying changes nothing |
| Where it runs | Once per request, in the pipeline | Wherever the resource is loaded |
| Depends on the object? | No | Almost always yes |
| Cost profile | Constant, cacheable | Grows with the sharing model |
| If it is broken | An attacker becomes someone else | Every user reaches everything |
The endpoint that looks safe
Model.objects.get(pk=...) or Model.find(params[:id]) instead of scoping from the current user's association — same bug, different spelling.Here is the shape of the most common serious vulnerability in web applications, written the way it actually appears in real code — not as an obvious mistake, but as a handler that passed review because the route had "auth" on it.
1app.get('/projects/:id', requireLogin, async (req, res) => {2 // requireLogin guarantees: there is a valid session.3 // It guarantees nothing about project 42.4 const project = await projects.findById(req.params.id)5 if (!project) return res.status(404).end()6 res.json(project)7})8 9// The fix is not stronger authentication. It is a second question:10app.get('/projects/:id', requireLogin, async (req, res) => {11 const project = await projects.findByIdForMember(req.params.id, req.session.userId)12 if (!project) return res.status(404).end() // not yours reads as not found13 res.json(project)14})The second version does not add a check after the read — it changes the read. The membership predicate is in the query, so there is no window in which the row exists in process memory for a caller who may not have it (Object-Level Authorization).
What each failure actually costs
Teams invest in authentication because it is legible: password hashing, MFA, token rotation are all named things with libraries. Authorization is diffuse, so it gets less attention — while carrying the larger blast radius in most products.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
Endpoint has requireLogin and no object check | Nothing. It works perfectly for honest users | Authentication treated as sufficient | Scope the read by the principal; add a test that user B gets 404 for user A's object. |
| Role read from a decoded, unverified JWT | A user is an admin without being granted anything | decode used instead of verify | Verify signature and issuer first; treat the payload as untrusted until then (The Trust Boundary). |
| Permissions embedded in a 30-day token | Demoted user still approving things | Permission lifetime tied to identity lifetime | Keep identity in the token, load permissions per request or cache them briefly. |
| 403 returned as 401 | Client stuck in a refresh loop, elevated load on the token endpoint | Status codes conflated | Map permission denials to 403 so clients stop retrying (An Error Taxonomy That Maps Cause to Response). |
| Internal service authenticates itself, then acts "as" a user | One compromised internal caller reads everything | Service identity used where user permission was needed | Propagate the user principal through the call chain and re-check at the boundary that owns the data (Request Context Propagation). |
How to build it
Most important first.
- Name things for the question they answer.
requireSessionandrequireCanEditProjectare reviewable;requireAuthis not. - Return 401 only when the credential is missing, malformed or expired. Return 403 when the principal is established and the answer is no. Conflating them makes clients retry authentication forever against a permission problem.
- Treat "authenticated" as the *start* of the decision, never the end. In a service with open signup,
req.sessionexisting tells you almost nothing about what should be permitted. - Keep the two layers separately testable: an authentication test asserts that a forged token is rejected; an authorization test asserts that user B cannot read user A's project. A codebase usually has the first and not the second.
- Decide deliberately whether a 403 should be a 404. Telling an attacker "this resource exists but is not yours" leaks the existence of objects; returning 404 for anything outside the caller's scope removes that signal at the cost of harder debugging.
What can go wrong
- An endpoint protected by
requireLoginonly, on a service where anyone can register — effectively public to the internet with one extra step. - Authorization decisions read from the token's unverified body. A JWT is only trustworthy after its signature is verified; decoding is not verifying (JWT — What It Is and What It Costs).
- Roles baked into a long-lived token, so a demotion has no effect until expiry. Identity can be long-lived; permissions usually should not be.
- 401 returned for a permission failure, causing well-behaved clients to loop: refresh the token, retry, get 401, refresh again (Retry Storms).
- Service-to-service calls that authenticate the *service* and then act on behalf of a user without carrying the user's permissions, so an internal call becomes an unbounded privilege (Microservices).
- A session or token stays valid after the underlying permission changes. Between demotion and expiry the two systems disagree, and authentication is the one that wins unless you check permissions per request.
- If authorization is absent and only authentication is present, an attacker gets full access to every other user's data with a normal registered account. This is the single most common serious finding in real applications, and the exploit is "change the number in the URL".
- If a permission is read from a token that is decoded but not signature-verified, an attacker gets arbitrary self-elevation: they edit the
roleclaim toadminand send it. Always verify, then read claims (JWT Failure Modes). - If roles live in a long-lived token with no revocation path, an attacker who compromises an account keeps its privileges for the full token lifetime even after you notice and demote them (Short-Lived Credentials).
- If 403 and 404 are used inconsistently, an attacker gets a resource-existence oracle: 403 means "exists, not yours", 404 means "does not exist", and iterating ids maps your entire object space.
- Strengthening authentication does not shrink the authorization hole. MFA on login means the attacker uses their own MFA-protected account to read your customers' data.
- "It is behind authentication, so it is protected." Protected from anonymous strangers. Not protected from your users.
- "OAuth is authorization, so if I use OAuth I have authorization." OAuth delegates *access* between systems and produces scopes; it does not decide whether this user may approve this expense (OAuth and OIDC From the Backend Side).
- "Scopes are permissions." A scope bounds what a token may be used for. Inside that scope, per-object authorization is still entirely your job (API Keys).
- "401 and 403 are interchangeable." They tell the client two different things to do. One says retry with credentials; the other says stop.
Operating it
- Split 401 and 403 in metrics. They are different systems failing; a dashboard that sums them as "auth errors" hides both (The Four Golden Signals).
- Alert on a rising 403 rate from a single principal — that is enumeration in progress. A rising 401 rate across many principals is usually an expiry or clock problem, not an attack.
- Log the reason:
authn.missing_token,authn.expired,authz.not_owner,authz.role_insufficient. "Unauthorized" as a log message is indistinguishable between the two systems.
- Authentication cost per request is roughly constant and cacheable — a signature verification or a session lookup. It rarely becomes the bottleneck.
- Authorization cost grows with the data model: more sharing, more groups, more hierarchy means more work per decision. That asymmetry is why the two are usually optimized differently.
- At larger team sizes, the naming problem gets worse rather than better. A shared
authmodule that does both is a place where two different reviewers assume the other one checked.
- Separating the two means more code and more names. A three-endpoint internal tool genuinely does not need a policy layer; it does still need to not confuse the two questions.
- Short-lived permission data means re-reading permissions frequently, which is load you did not have when they were in the token. That is the cost of timely revocation.
- Returning 404 instead of 403 protects object existence and makes every support ticket harder: "it says not found but I can see it in the sidebar" is a genuinely confusing bug report.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe distinction holds everywhere. The 401/403 mapping is HTTP-specific but the underlying "prove identity" versus "permission denied" split exists in gRPC status codes (UNAUTHENTICATED vs PERMISSION_DENIED) and in every RPC system that bothered to separate them.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Identity and access management as an organizational system — provisioning, joiners/movers/leavers, and access review — sits above what a single backend implements.