Three questions get conflated constantly, and separating them is most of the design work. Authentication answers who or what is making this request. A session answers which later requests belong to that same principal. Authorization answers may this principal perform this action on this resource. They run in that order, at every request, on the server — and a failure in any one of them is a different class of incident.
The order of operations in one request
Every authenticated request runs the same pipeline, and each stage has its own failure mode and its own status code:
TLS terminates -> transport identity of the connection
session cookie/token -> which principal claims to be here
session lookup -> is it live? expired? revoked? (401 if not)
resolve identity -> user, tenant, roles, MFA state
authorize the action -> may THIS principal do THIS on THIS object? (403 if not)
scope the query -> the data access itself is filtered by identity
audit -> who, what, when, on which objectTwo rules make this structure pay off. Deny by default — authorization is a positive
check you write, and any path that skips it silently becomes public. Distinguish 401 from
403 — 401 means "we don't know who you are" (re-authenticate), 403 means "we know who
you are and the answer is no" (don't retry with the same credentials). Collapsing both into
one status code makes client-side retry logic guess.
Authentication: where the attack surface actually is
The login endpoint is a public API whose failure modes are exploitable:
- Enumeration. "No account with that email" tells an attacker which addresses to try.
Answer
401with a uniform "invalid email or password" — and remember that a faster response for unknown accounts leaks the same information through timing, because you skip the password hash. Compute a dummy hash against a fixed fake verifier when the user does not exist so both paths cost the same ~100 ms. - Credential stuffing. Rate limit per account and per source IP; return
429withRetry-Afterrather than a generic error, and lock out or force a step-up after a handful of failures:
$ curl -i -X POST https://api.example.com/login \
-H 'content-type: application/json' \
-d '{"email":"dev@example.com","password":"hunter2"}'
HTTP/2 429
retry-after: 30- Reset flows. Single-use tokens with a short TTL (10–15 minutes), invalidated on password change, and a response that does not reveal whether the account exists.
- Step-up authentication. A valid session proves someone logged in earlier, not that the current request deserves the action. Changing an email, issuing an API key, or moving money should require a fresh factor (password or WebAuthn assertion), not only the cookie.
Building a session the right way
After a successful login (the password side is in password storage and cryptography), the server creates a session and hands the client an opaque identifier:
HTTP/1.1 302 Found
Set-Cookie: sid=9f4c1e2b7a8d6f3c5e0b1a9d8c7f6e5b; Path=/; HttpOnly; Secure; \
SameSite=Lax; Max-Age=604800The pieces matter individually:
- Random, not guessable. 128 bits from a CSPRNG — 32 hex characters — puts the session
space at 2^128. Sequential IDs (
session=1041,session=1042) are an authentication bypass you can perform with the keyboard's up-arrow key. - Stored server-side. The cookie carries an identifier; the state (user ID, expiry, permissions, MFA flag) lives in a Redis row or a database table. Lookup is 0.5–2 ms, and deleting the row logs the user out everywhere — which is the property you want for "sign out all sessions."
- Rotated on privilege change. Regenerate the ID at login and at password change. This is the fix for session fixation, where an attacker plants a known session ID (usually via a URL parameter) and waits for the victim to authenticate it.
- Expiring. Idle timeout plus absolute timeout.
Max-Ageon the cookie is only the client's copy; enforce the real expiry server-side, because cookies are editable.
Tokens: what a signed JWT actually changes
A JWT moves the session state into the token itself: the server signs claims
(sub, exp, role), and any service can verify the signature without a lookup. Verification
costs roughly 50–200 µs of CPU versus 0.5–2 ms for a store lookup — real, but rarely the
bottleneck in a request that spends 30 ms in a database.
The costs are structural:
- Revocation is impossible without state. "Log out" and "disable this user" cannot
invalidate a signed token that any verifier accepts. Workarounds are all state again: a
short
exp(5–15 minutes) plus a rotating refresh token, or a denylist checked per request. - Freshness lags. A role removed from a user's claims still applies until the token
expires. For high-value actions, re-check authorization against current state instead of
trusting
exp. - Clock skew is real. Verifiers need leeway — typically 30–60 seconds — or requests fail at token boundaries when machines drift.
- Storage location is a security choice. Cookies are sent automatically (CSRF exposure,
mitigated by
SameSite); anAuthorizationheader is not sent cross-site (no CSRF) but must be readable by JavaScript if the token lives inlocalStorage, which any XSS can steal.
Authorization at the resource boundary
The most common broken-access-control bug in web applications:
GET /orders/123 -> authenticated as user A, order 123 belongs to user BThe authentication passed, the route existed, the ID looked plausible — and the handler loaded the row by primary key and returned it. The missing check is object-level authorization, executed server-side for every protected operation:
async function getOrder(req, res) {
const order = await db.orders.findUnique({ where: { id: req.params.id } });
// 404 rather than 403: don't confirm that someone else's order ID exists.
if (!order || order.tenantId !== req.session.tenantId) return res.status(404).end();
return res.json(order);
}Two habits prevent the class. First, deny by default: the safe query is the one scoped by
the caller's identity (WHERE id = $1 AND tenant_id = $2), not a query plus an after-the-fact
comparison. Second, test it as a test, not as a review — an integration test that logs in
as user A, creates a resource, then fetches it as user B and asserts 404 catches every
regression of this type, including the new endpoint someone added last week.
Hiding a button, disabling a link, or returning a filtered list is not access control; it is
UX. A client can send /orders/124 directly.
Where this bites in production
- IDOR reports arrive as "user X saw user Y's data" and are almost always a handler that
fetched by ID without scoping. Audit every
find/SELECTon a route parameter. - Session fixation and missing rotation surface only when someone actually steals an ID — rotate on login and on any privilege change.
- Long-lived JWTs turn every account-disable into a race: the user stays privileged until
exp. Keep access tokens short (5–15 minutes) and make revocation a server-side check for sensitive operations. - Cookie flags missing in one response is enough: a single
Set-CookiewithoutHttpOnlyhands sessions to any XSS payload. Assert the flags in an integration test.
Further reading: the OWASP authorization cheat sheet.