Overview
Curated: · Written: · Reviewed:
Authentication is who; authorisation is what they may do
Authentication establishes identity: this request comes from user 4821. Authorisation decides permission: may user 4821 read this order? They are distinct problems, and conflating them is the source of the most common serious API vulnerability.
The distinction is visible in the response. 401 Unauthorized means "authenticate and try again" — the credential is missing, malformed or expired. 403 Forbidden means "you are authenticated and still may not do this" — authenticating differently will not help. Clients behave differently: a 401 triggers a token refresh, a 403 should not. Conflating them makes clients loop refreshing tokens that were never the problem.
Where each one is enforced
Authentication is usually solved once, at the edge: an API gateway or middleware validates the credential, rejects 401s, and populates a caller context that travels with the request. That is the right place for it.
Authorisation is where the layering argument starts, and interviewers probe it directly. The failure pattern is a team that says "the gateway authenticated it" as though that were an authorisation argument. It is not: the gateway proves who, and says nothing about which row. A request with a perfectly valid token for user 4821 still has to be checked against the object it touches.
The defensible positions, in increasing strength:
- In the handler — every endpoint asks "may this user touch this object". Works until one endpoint forgets, and the one that forgot looks identical to the ones that did not.
- In a data-access layer — every query is required to carry a caller context (
WHERE tenant_id = :caller_tenant), so an endpoint cannot fetch another tenant's row without bypassing the layer. - In the database — Postgres row-level security, for example, with policies bound to a session variable set per request. The database refuses the row even if the application code is wrong.
The weak answer sounds like "we check the user's role on each endpoint". The strong answer names the layer that makes the check unavoidable and admits the residual risk: an RLS policy keyed on a session variable is only as trustworthy as whatever sets that variable.
Object-level authorisation is where APIs actually fail
OWASP's API Security Top 10 ranks broken object-level authorization first (API1 in the 2023 edition), and it comes from checking authentication but not ownership. An endpoint verifies a valid token, then serves whatever identifier appeared in the URL. Any authenticated user can substitute another identifier and read someone else's data.
The correct question is never "may this user read orders" but "may this user read this order" — and it must be enforced structurally, per the layering above, not by every endpoint author remembering.
The related failure is broken function-level authorization: an administrative endpoint that checks authentication but not role, discoverable by anyone who guesses the path.
Opaque identifiers reduce the blast radius by making enumeration harder. They do not fix anything. Unguessability is not an access control, and treating it as one fails as soon as an identifier is shared, logged or appears in a referrer header.
Credentials side by side
What each credential actually proves — and what it does not:
| Credential | Proves | Does not prove | Fits |
|---|---|---|---|
| API key | A known integration holds this string | Which user, that the holder is current, anything about intent | Server-to-server where a full flow is overkill |
| Session cookie | The browser holds a server-side session ID | Anything to a non-browser client; safe only with cookie attributes set | Classic web apps, one first-party origin |
| JWT (self-issued) | Claims you signed yourself | Third-party identity, delegation to another app | First-party APIs, internal services |
| OAuth access token | The bearer is authorised for the granted scopes on that resource | Who the end user is (that is the ID token's job) | Third-party integrations, delegated access |
| mTLS / workload identity | The peer holds a specific private key / is a specific workload | Anything about an end user | Service-to-service inside the mesh or network |
API keys should be scoped, revocable, rotatable, identifiable to a specific integration, and stored hashed like passwords. mTLS suits internal communication where certificate management already exists. Whatever the credential: TLS in transit, hashed or encrypted at rest, and revocation that is possible and fast — a credential you cannot revoke is a credential you cannot lose safely.
OAuth 2.0 is delegation, not login
OAuth 2.0 exists so a user can grant an application limited access to their data on another service without sharing their password. It is an authorisation-delegation framework. Using it as authentication — "log in with OAuth" — uses it for something it does not define; that is what OpenID Connect adds on top: an identity layer with an ID token that says who the user is, distinct from the access token that grants access to resources. An access token should never be treated as proof of identity; an ID token should never be sent to a resource server as a credential.
Current guidance (OAuth 2.1 draft, and the security BCP before it) is narrower than the original spec:
- Authorization code + PKCE is the recommended flow for every client type, including single-page and mobile apps — the only browser flow.
- Client credentials grant is for services acting as themselves, no user involved.
- The implicit grant and resource owner password credentials grant are deprecated — the first because tokens in redirect fragments leak, the second because the app handles the user's password, defeating the point.
PKCE binds an authorization code to the client that requested it. The client generates a high-entropy code_verifier, sends SHA256(verifier) as the code_challenge, and must present the original verifier to exchange the code. A trace of why an interception fails:
- Client generates verifier
dBjftJeZ4CVP..., sends challengeE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMwith the authorization request. - Attacker intercepts the redirect and grabs the code — but the code alone is useless.
- Attacker redeems the code at the token endpoint. The server asks for the
code_verifier. - The attacker cannot produce a verifier that hashes to the challenge; the exchange fails. Only the party that created the challenge can.
Exact redirect URI matching matters for the same reason — pattern matching has repeatedly been exploited to redirect codes to attacker-controlled endpoints.
Scopes limit what a token may do; a token that can do everything is a token whose compromise is total. Refresh-token rotation closes the other gap: the issuer issues a new refresh token with each use and invalidates the family if a rotated token is presented twice — reuse detection turns a stolen refresh token into an alarm rather than a persistent back door.
JWT mechanics and failure modes
A JWT is three base64url parts joined by dots: header.payload.signature. The header names the algorithm; the payload carries claims; the signature lets a resource server validate without calling the issuer. That is the appeal: no round trip, no shared session store, easy horizontal scaling.
The cost is revocation. A stateless token is valid until it expires, so logout, account disable or scope change does not touch tokens already issued. The standard answer is the access/refresh split: short-lived access tokens (minutes), longer-lived refresh tokens held server-side and revocable. Sensitive cases can add a revocation list checked per request. What is not acceptable is issuing a long-lived stateless token and describing the system as having logout.
Validation must be complete, and two classic attacks target exactly the incomplete version:
alg: none— a token whose header claims no signature; a verifier that trusts the header accepts an unsigned, freely editable payload.- Algorithm confusion — a token signed with the RSA public key as an HMAC secret, accepted by a verifier that switches to HS256 because the header said so.
Both are defeated by deciding the expected algorithm server-side. In Python (checked against PyJWT 2.x):
import jwt
# WRONG: trusts the token's header to pick the algorithm
jwt.decode(token, key, options={"verify_exp": True})
# RIGHT: algorithm pinned server-side, all standard claims enforced
claims = jwt.decode(
token,
key=public_key,
algorithms=["RS256"], # allowlist, never read from the header
audience="https://api.example.com",
issuer="https://auth.example.com",
)
# PyJWT verifies exp, nbf, aud, iss by default when supplied above;
# alg:none and HS256-with-an-RSA-key both fail here because "alg" is not honoured.
A JWT payload is encoded, not encrypted. Anyone holding the token reads every claim, so no secret belongs in one.
Opaque tokens with server-side lookup invert the trade: instant revocation and private claims, at the cost of a lookup per request. For many systems that lookup is cheap and the operational simplicity beats the saved round trip — the stateless choice is frequently made by default rather than decided.
Token storage in the browser
Where the client keeps the token decides which attack lands:
| Storage | XSS steals it? | CSRF forces it? | Notes |
|---|---|---|---|
localStorage | Yes, trivially | No | Any script on the page reads it; one XSS bug is total token compromise |
| httpOnly + Secure + SameSite cookie | No — script cannot read it | Yes, unless SameSite/CSRF token mitigates | Server sets the cookie; the browser attaches it automatically |
| In memory (JS variable) | Only while the page lives | No | Refresh token in an httpOnly cookie rehydrates it after reload |
XSS is token theft; CSRF is forced action. httpOnly cookies defend the first and require defence against the second (SameSite, CSRF tokens); localStorage is the reverse. CORS is not an auth control — it is a browser-enforced read policy; a non-browser client ignores it entirely, and it constrains what scripts may read, not what requests may be sent. An interviewer asking "why not just tighten CORS?" wants to hear that.
Modelling permissions
Role-based access control assigns permissions to roles and roles to users. Simple, comprehensible, and it degrades into role explosion when exceptions accumulate.
Attribute-based access control decides from attributes of user, resource, action and context — "a manager may approve expenses under £5,000 in their own department" — more expressive, harder to reason about and test.
Relationship-based access control decides from the graph between subject and object, which suits document sharing and hierarchical ownership.
Most systems want roles for the broad strokes and attributes or relationships for the specific cases. Whatever the model: the decision is enforced in one place, and it is testable — for every endpoint, a test that another user's resource is inaccessible. That test is tedious, almost always missing, and catches the vulnerability class that actually gets exploited.
Worked example: role=user is not an object ACL
GET /invoices/4419. Token sub is tenant B. Invoice 4419 belongs to tenant A.
| check | HTTP | tenant A invoice |
|---|---|---|
| JWT role is user | 200 | leaked |
server loads invoice, compares tenant_id to token tenant | 404 | hidden |
A signed role claim is not authorisation for a row. The object check lives on the server — ideally in a layer the handler cannot skip:
-- Postgres RLS: the handler cannot forget, because the database enforces it
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- per request: SET LOCAL app.tenant_id = '<from verified token>';
What interviewers probe, and the weak versions
- "The gateway authenticated the request — why did data leak?" Weak: "the gateway handles security." Strong: authentication is who, authorisation is which row, and the gateway only answers the first.
- "How do you log out a JWT?" Weak: "delete it client-side." Strong: short access-token expiry plus a revocable refresh token; name the residual window explicitly.
- "Why PKCE for a SPA that can't keep a secret?" Weak: "SPAs are insecure." Strong: PKCE removes the need for a client secret by binding the code to a verifier only the requesting client holds.
- "Where do you store tokens in the browser?" Weak: "localStorage is fine." Strong: the XSS/CSRF trade in the table above, and why CORS does not answer it.
- "RS256 or HS256?" Weak: picking one by habit. Strong: pin the algorithm server-side; explain
alg:noneand algorithm confusion as the reason.
