Auth over HTTP: sessions, bearer tokens and JWTs
- Trace how credentials travel in a cookie session and with a bearer access token, and say where the state lives in each
- Decode a JWT's three parts by hand, and explain why a signed token is readable by anyone who holds it
- Verify an access token the way a library does: pin the algorithm, check the signature, then
expin seconds - Answer with 401 +
WWW-Authenticateor 403 per RFC 9110 and RFC 6750, and react to each as a client - Explain refresh-token rotation and reuse detection, and read
Set-Cookieattributes as mechanics
Read
Two ways to say who you are — a cookie session or a bearer token
FlagHTTP is stateless: every request must carry its own proof of who sent it. There are two common ways to do that, and they differ in who attaches the proof and where the state lives.
A cookie session
POST /login (email + password in the body)
HTTP/1.1 204 No Content
Set-Cookie: sid=9f2c1e…; HttpOnly; Secure; SameSite=Lax; Path=/
GET /me
Cookie: sid=9f2c1e…
sidis a random, meaningless id. The server keeps a session store (a table, Redis):9f2c1e… → { userId: "u_1", expiresAt: … }, and looks it up on every request.- The browser attaches the cookie by itself, to requests that match its domain, path and
other attributes (the last example in this unit). Your JavaScript doesn't touch it (with
HttpOnly, it can't). - Logging out — or banning a user — is deleting the row. The next request fails immediately.
A bearer access token
POST /auth/login (email + password in the body)
HTTP/1.1 200 OK
Content-Type: application/json
{"accessToken":"eyJhbGciOi…","tokenType":"Bearer","expiresIn":900}
GET /me
Authorization: Bearer eyJhbGciOi…
- The token is usually a JWT: the claims (
sub: "u_1", an expiry) are inside it, signed by the server. Verifying it is arithmetic on the token, no database lookup. - Your code attaches the header, on every request, and decides where to keep the token between requests. "Bearer" means whoever holds the token is let in (RFC 6750) — like cash.
- It can't be cancelled early without adding a lookup back in. That's why access tokens are short (minutes), with a longer-lived refresh token to get new ones.
Two separate choices
The token's format (opaque id or self-contained JWT) and its transport (cookie or
Authorization header) are independent. A JWT can ride in an HttpOnly cookie; an opaque id can
be sent as a bearer token. Browser apps lean on cookies because the browser attaches them for
you; CLIs, service-to-service calls and public APIs use the header, which every HTTP client can
set and which nothing sends unless your code does.
Which storage is safer in a browser is an attack-and-defence question — XSS and CSRF — and
belongs to the Web Security track.
Verifying an access token — the order of the checks is the security
FlagDecoding reads the claims. Verifying decides whether to believe them. A server that decodes
an incoming token and trusts the sub inside has no authentication at all: anyone can write
{"sub":"admin"}, base64url it, and send it.
Here are the checks a JWT library performs, written out with Web Crypto (available in browsers
and in Node as the global crypto) so nothing is hidden:
const ACCEPTED_ALG = "HS256";
// atob and JSON.parse throw on junk. A verifier answers null; it never throws.
function orNull<T>(read: () => T): T | null {
try { return read(); } catch { return null; }
}
async function verifyAccessToken(token: string, secret: string, nowSeconds: number) {
const [h, p, s, extra] = token.split(".");
if (!h || !p || !s || extra !== undefined) return null; // exactly three parts
// 1. Pin the algorithm. The header is attacker-controlled: never let it choose.
const header = orNull(() => decodeSegment(h)) as { alg?: string } | null;
if (!header || header.alg !== ACCEPTED_ALG) return null;
// 2. Check the signature over the exact text "header.payload".
const signature = orNull(() => base64urlToBytes(s));
if (!signature) return null;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
const signed = new TextEncoder().encode(`${h}.${p}`);
if (!(await crypto.subtle.verify("HMAC", key, signature, signed))) return null;
// 3. Only now read the claims — and check exp, which is in SECONDS.
const claims = orNull(() => decodeSegment(p)) as { sub?: string; exp?: number } | null;
if (!claims || typeof claims.exp !== "number" || nowSeconds >= claims.exp) return null;
return claims;
}
(decodeSegment is the helper from the previous example; base64urlToBytes is the same
conversion without the JSON parse.) Run against the token from the curl unit: a fresh token
returns its claims; the same token at its exp second, a copy with an edited payload, and a token
whose header says "alg":"none" all return null — and so do a segment that isn't base64url
(!!) and a header that decodes to the JSON null.
Why each line is there
- Junk in,
nullout. The header arrives before anything is verified, so it's whatever the sender typed. LetatoborJSON.parsethrow there and a garbage token becomes a500instead of a401.!headercovers the other trap: a header that decodes to the JSONnull, whereheader.algwould throw aTypeError. - Pinning
alg. RFC 8725 (JWT best practices) lists the classic attacks: libraries that accepted"alg":"none"and skipped the signature, and ones that let an attacker switchRS256toHS256so a public key became the HMAC secret. A verifier that runs whatever the header names has handed the decision to the attacker. - Signature before claims. Until the signature checks out, every claim is just text someone
typed — checking
exporscopeon an unverified payload checks the attacker's own claims. expin seconds. RFC 7519 defines it as a NumericDate: seconds since the epoch. The token is invalid "on or after" that instant, hence>=. Comparing it withDate.now()(milliseconds) makes every token look a thousand times older — or, the other way round, never expire.
In production, use a maintained library and tell it the algorithm. With jose v6:
import { jwtVerify } from "jose";
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret), {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "orders-api",
requiredClaims: ["exp"],
});
jose never accepts unsecured ("alg":"none") tokens, rejects an expired one, and with issuer
and audience also rejects tokens minted for a different service. It throws on failure — there is
no "decode and hope" path. One default to know: it checks exp and nbf only when they're
present, so a token with no exp at all verifies — requiredClaims: ["exp"] is what refuses it
(checked with jose 6.2).
Refresh tokens, rotation, and the refresh race you cause yourself
FlagA JWT access token can't be revoked before its exp without adding a lookup back in, so you keep
it short — the local API's lasts 900 seconds. A refresh token is the long-lived credential
that gets new access tokens: opaque, stored server-side, sent only to the auth endpoints (the
API sets it as an HttpOnly cookie with Path=/auth, so the browser sends it to /auth and the
paths below it — /auth/refresh, but also /auth/login and /auth/signup — and nowhere else).
$ curl -i -b jar.txt -c jar.txt -X POST http://localhost:4123/auth/refresh
HTTP/1.1 200 OK
Set-Cookie: refresh_token=-Zrw4CiF-HiFN9vvibZa_55z2_WAhusl; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=1209600
…
{"accessToken":"eyJhbGciOiJIUzI1NiIs…","tokenType":"Bearer","expiresIn":900}
Rotation: every refresh token is single-use
Notice the response sets a new refresh token. That's rotation, and RFC 9700 (the OAuth 2.0 Security Best Current Practice, January 2025) describes why: the server issues a new refresh token with every refresh and invalidates the old one, but remembers they belong together. If a stolen token and the real one are both used, one of them is necessarily an already-used token — and the server then revokes the active refresh token as well (in practice, the whole family):
$ curl -i -b jar-old.txt -X POST http://localhost:4123/auth/refresh # replay the used one
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
…
{"error":"refresh token reused; session revoked"}
$ curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -X POST http://localhost:4123/auth/refresh
401
The second command uses the newest, legitimate token — and fails too. The server can't tell which party was the thief, so it ends the session for both. The RFC's own words: this "stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant".
The race your own client can cause
A page loads, fires five API calls, and the access token has just expired. Five 401s come back
at once; five handlers each call refresh(). The first rotates the token; the other four send the
now-used one — and reuse detection logs the user out. No attacker required.
The fix is to make refreshing single-flight: everyone waiting for a token shares one request.
let refreshing: Promise<string> | null = null;
function getFreshToken(): Promise<string> {
// Concurrent callers get the same promise; the next refresh starts after this one settles.
refreshing ??= refresh().finally(() => {
refreshing = null;
});
return refreshing;
}
Three concurrent calls to getFreshToken() produce one refresh() and three copies of the same
token. Retry each failed request once with that token; if it still gets 401, send the user to
log in instead of refreshing again.
Quiz
In a cookie-session app, what puts the session id on each request to the API?
FlagYou ban a user. With server-side sessions their next request fails; with 15-minute JWT access tokens they keep working for up to 15 minutes. Why?
FlagA teammate puts the user's email and billing plan in the JWT payload: "it's fine, the token is encrypted with HS256". Is it?
FlagA verifier runs if (header.alg === "HS256" && !(await verifySignature(token))) reject(); and accepts anything else. What does an attacker send?
A valid, unexpired access token with scope: "orders:read" calls DELETE /orders/7, which needs orders:write. What should the API answer?
Your single-page app gets 401 with error="invalid_token" on GET /me. What should it do?
The server rotates refresh tokens and revokes the whole family when a used one comes back. Two tabs notice an expired access token and both call /auth/refresh with the same refresh token at the same moment. What happens?
Set-Cookie: sid=9f2c1e; HttpOnly. Which statement is true?
Fill in the blank
The access token expired. Write the status code and the header a 401 must carry.
HTTP/1.1 Unauthorized : Bearer realm="api", error="invalid_token"
The token is fine; the user just isn't an admin.
FlagHTTP/1.1 403 Forbidden WWW-Authenticate: Bearer realm="api", error="", scope="admin"
Reject the token from its expiry second on. exp is a NumericDate.
const nowSeconds = Math.floor(Date.now() / ); if (typeof claims.exp !== "number" || nowSeconds claims.exp) return invalid;
Keep the refresh token away from scripts, and send it only to the /auth endpoints.
Set-Cookie: refresh_token=pi1B33Pk; ; Secure; SameSite=Strict; =/auth; Max-Age=1209600
With jose v6, pin the accepted algorithm and the intended audience.
const { payload } = await jwtVerify(token, key, { : ["HS256"], issuer: "https://auth.example.com", : "orders-api", });
Predict the output
// The payload segment of a token for a user named "Zoë" const segment = "eyJzdWIiOiJ1XzEiLCJuYW1lIjoiWm_DqyJ9"; console.log(JSON.parse(atob(segment)));
const segment = "eyJzdWIiOiJ1XzEiLCJuYW1lIjoiWm_DqyJ9"; const base64 = segment.replace(/-/g, "+").replace(/_/g, "/"); console.log(JSON.parse(atob(base64)).name);
// Issuer, at login on 2026-10-17:
const exp = Date.now() + 15 * 60 * 1000; // "15 minutes"
// Verifier, on every request:
const nowSeconds = Math.floor(Date.now() / 1000);
if (nowSeconds >= claims.exp) return reject("expired");Challenge
Fix the bearer-token check in front of the orders API
Flagauthenticate() runs in front of every orders endpoint. It gets the request's Authorization
header and the scope the route needs, and decides: let the request through, answer 401, or
answer 403. It "works" for the happy path — and a pen-test report just came back with
findings against it.
The rules (RFC 7519 for JWTs, RFC 9110 and RFC 6750 for the responses):
- This API only issues
HS256tokens signed withoptions.secret. The token's own header is written by whoever sent it, so it must not decide how — or whether — the token is checked. expis a NumericDate: seconds since 1970. The token is invalid from that second on.options.nowSecondsis the clock. Access tokens without anexpare not accepted.- No credentials at all →
401with exactlyBearer realm="api"(noerror). - Credentials that don't check out →
401withBearer realm="api", error="invalid_token". - A genuine token without the needed scope →
403withBearer realm="api", error="insufficient_scope", scope="<the scope>". - The scheme name
Beareris case-insensitive.
jwt.ts already does the byte work: decodeSegment() (returns null instead of throwing) and
verifyHs256(). In production, a maintained library such as jose does this for you when you
pass algorithms: ["HS256"] — except one rule: jose only checks exp when the token has one,
so refusing tokens without an exp takes requiredClaims: ["exp"]. This exercise is about
knowing what those options check.
Your task
Edit authenticate.ts so the tests pass:
- A valid
HS256token with the required scope is accepted, withuserIdfromsub - No credentials →
401withWWW-Authenticate: Bearer realm="api"and no error code - The
Bearerscheme is matched case-insensitively - A token that isn't a correctly signed
HS256token — edited payload, wrong secret,alg: none, malformed — gets401witherror="invalid_token" - A token is rejected from its
expsecond on (expis in seconds), and a token with noexpis rejected - A valid token without the required scope gets
403witherror="insufficient_scope", scope="…"
Further reading
- RFC 9110: HTTP Semantics (401, 403, WWW-Authenticate)
- RFC 6750: OAuth 2.0 Bearer Token Usage
- RFC 7519: JSON Web Token (JWT)
- RFC 7515: JSON Web Signature (JWS)
- RFC 8725: JWT Best Current Practices
- RFC 9700: OAuth 2.0 Security Best Current Practice (refresh token rotation)
- RFC 6265: HTTP State Management (cookies)
- MDN: Set-Cookie
- MDN: Window.atob()
- MDN: SubtleCrypto.verify()
- jose v6: jwtVerify verification options