AuthzGENERAL

Authentication vs Authorization

"Who are you" and "may you do this" are different questions with different answers, different failure modes and different blast radii.

What actually happensHow to build it

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.

The question

Why is conflating authentication and authorization the most common serious backend security mistake?

The requirement

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 obvious build

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.

Why it breaks

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.

How it breaks in production
  • 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.
  • 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.

AuthenticationAuthorization
QuestionWhich principal is this?May this principal do this to this?
InputA credential: password, token, key, cookieA principal, an action, a resource, context
AnswerAn identity, or nothingAllow or deny, for one specific attempt
HTTP failure401 — retry with a credential403 — retrying changes nothing
Where it runsOnce per request, in the pipelineWherever the resource is loaded
Depends on the object?NoAlmost always yes
Cost profileConstant, cacheableGrows with the sharing model
If it is brokenAn attacker becomes someone elseEvery user reaches everything

The endpoint that looks safe

FRAMEWORK-SPECIFICExpress is shown because its middleware makes the gap most visible. In Django or Rails the same gap appears as a view that calls 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.

Authenticated, not authorized
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 found
13 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.

Confusing the two, and what an attacker gets
TriggerSymptomCauseResponse
Endpoint has requireLogin and no object checkNothing. It works perfectly for honest usersAuthentication treated as sufficientScope the read by the principal; add a test that user B gets 404 for user A's object.
Role read from a decoded, unverified JWTA user is an admin without being granted anythingdecode used instead of verifyVerify signature and issuer first; treat the payload as untrusted until then (The Trust Boundary).
Permissions embedded in a 30-day tokenDemoted user still approving thingsPermission lifetime tied to identity lifetimeKeep identity in the token, load permissions per request or cache them briefly.
403 returned as 401Client stuck in a refresh loop, elevated load on the token endpointStatus codes conflatedMap permission denials to 403 so clients stop retrying (An Error Taxonomy That Maps Cause to Response).
Internal service authenticates itself, then acts "as" a userOne compromised internal caller reads everythingService identity used where user permission was neededPropagate 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. requireSession and requireCanEditProject are reviewable; requireAuth is 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.session existing 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

Failure modes
  • An endpoint protected by requireLogin only, 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).
What can race
  • 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.
Security
  • 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 role claim to admin and 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.
Misreads
  • "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

How you see it in production
  • 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.
What changes at 10x and 100x
  • 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 auth module that does both is a place where two different reviewers assume the other one checked.
What this costs
  • 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.

Domains that do not exist yet
  • Identity and access management as an organizational system — provisioning, joiners/movers/leavers, and access review — sits above what a single backend implements.