Authentication, authorization, 401 and 403
Who is calling versus what they may do, bearer tokens and their challenges, scopes, token lifetimes, and the object-level check that most API breaches skip.
After this lesson you can
- Choose correctly between 401 and 403 and send the right WWW-Authenticate challenge
- Check a token's expiry, audience and scopes without the common off-by-one and substring bugs
- Explain why short-lived access tokens pair with refresh tokens
- Describe broken object-level authorization and how to prevent it
Authentication answers "who is calling?". Authorization answers "may this caller do this, to this resource?". They fail differently, and HTTP has a status code for each — with names that, confusingly, swap the words:
- 401 Unauthorized means not authenticated: no credentials, or
credentials the server does not accept (unknown, expired, malformed).
RFC 9110 requires a 401 to carry a
WWW-Authenticateheader with at least one challenge saying how to authenticate. - 403 Forbidden means authenticated, and refused. Sending the same credentials again will not help.
Bearer tokens
Most APIs authenticate with a bearer token (RFC 6750) — whoever holds it
can use it — sent in the Authorization header:
GET /orders HTTP/1.1
Authorization: Bearer eyJhbGciOiJFUzI1NiJ9...
Not in the query string: URLs end up in logs, browser history and
Referer headers (RFC 6750 does define an access_token query
parameter, but advises against it for exactly that reason). RFC 6750 also defines the error codes for the
challenge: a missing token gets a bare challenge, a bad or expired one
gets error="invalid_token" with 401, and a valid token without enough
privilege gets error="insufficient_scope" with 403.
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="api", error="insufficient_scope", scope="orders:write"
Scopes
In OAuth 2.0 a token carries scopes: a space-separated list of
permissions the user granted to this client, such as
orders:read orders:write. An endpoint declares the scope it needs and
checks the list — as whole items, never as a substring:
const naive = (granted, needed) => granted.includes(needed);const exact = (granted, needed) => granted.split(" ").filter(Boolean).includes(needed); export function run() { const cases = [ ["orders:read orders:write", "orders:write"], ["orders:readwrite", "orders:read"], ["admin:orders", "orders"], ]; return cases.map(([g, n]) => `"${g}" needs "${n}": naive ${naive(g, n)}, exact ${exact(g, n)}`);}Scopes limit what a client application may do on a user's behalf. They do not replace checking what the user may do.
Object-level authorization
A valid token with the right scope, asking for GET /orders/1043 — is
order 1043 this user's? Forgetting that check lets any customer read any
order by changing a number in the URL. The OWASP API Security Top 10
(2023) lists it first, as Broken Object Level Authorization. Every
handler that takes an id must verify the caller's right to that object,
and answering 404 rather than 403 avoids confirming the object exists.
Token lifetimes
A self-contained token such as a JWT is checked without a database call, which also means it cannot easily be revoked: it is valid until it expires. So access tokens are short-lived (minutes), limiting how long a leaked one is useful, and a longer-lived refresh token, kept more carefully and checkable against the server, is exchanged for new access tokens.
Validating a JWT means verifying the signature with an algorithm you
expect, then the claims (RFC 7519): exp — reject once the current time
is at or after it; nbf — reject before it; iss, the issuer you trust;
aud, which must name this API. Servers usually allow a small leeway
for clock skew between machines, typically under a few minutes.
function checkClaims(claims, now, audience, leeway) { const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; if (!auds.includes(audience)) return "reject: wrong audience"; if (now >= claims.exp + leeway) return "reject: expired"; if (now < claims.nbf - leeway) return "reject: not yet valid"; return "accept";} export function run() { const claims = { aud: ["orders-api", "billing-api"], nbf: 1000, exp: 1600 }; const at = (now, leeway, aud = "orders-api") => `now=${now} leeway=${leeway}: ${checkClaims(claims, now, aud, leeway)}`; return [ at(1599, 0), at(1600, 0), at(1630, 60), at(970, 60), at(1200, 0, "search-api"), ];}API keys are different again: long-lived secrets identifying a project or integration rather than a user. Store only a hash of them, let customers rotate them, and never accept them in URLs.
Common mistakes
- 403 for a missing or expired token (it is 401, with a challenge), or 401 for a valid user who lacks permission (that is 403).
- Matching scopes with
includeson the raw string, soorders:readwritesatisfiesorders:read. - Accepting a token at the exact second of its
exp, or with no audience check, so a token issued for another API works on yours. - Checking the token and the scope but not whether the caller owns the object in the URL.
What interviewers ask
- 401 or 403? — 401 when the caller is not (validly) authenticated, with
WWW-Authenticate; 403 when they are and are not allowed. - Why are access tokens short-lived? — a stateless token cannot easily be revoked, so a short lifetime caps the damage of a leak; refresh tokens get new ones.
- What is BOLA? — broken object-level authorization: trusting an id in the request without checking the caller may access that object.
Exercise · Pro
A bearer-token gate
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade