HTTP & APIs in Practice
HTTP & APIs in Practice

Auth over HTTP: sessions, bearer tokens and JWTs

Full · ~40 min
After this unit you can
  • 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 exp in seconds
  • Answer with 401 + WWW-Authenticate or 403 per RFC 9110 and RFC 6750, and react to each as a client
  • Explain refresh-token rotation and reuse detection, and read Set-Cookie attributes as mechanics

Read

Simple
1 / 2

Two ways to say who you are — a cookie session or a bearer token

Flag

HTTP 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…
  • sid is 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.

Medium
1 / 2

Verifying an access token — the order of the checks is the security

Flag

Decoding 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, null out. The header arrives before anything is verified, so it's whatever the sender typed. Let atob or JSON.parse throw there and a garbage token becomes a 500 instead of a 401. !header covers the other trap: a header that decodes to the JSON null, where header.alg would throw a TypeError.
  • 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 switch RS256 to HS256 so 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 exp or scope on an unverified payload checks the attacker's own claims.
  • exp in seconds. RFC 7519 defines it as a NumericDate: seconds since the epoch. The token is invalid "on or after" that instant, hence >=. Comparing it with Date.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).

Complex
1 / 2

Refresh tokens, rotation, and the refresh race you cause yourself

Flag

A 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?

Flag

You 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?

Flag

A teammate puts the user's email and billing plan in the JWT payload: "it's fine, the token is encrypted with HS256". Is it?

Flag

A verifier runs if (header.alg === "HS256" && !(await verifySignature(token))) reject(); and accepts anything else. What does an attacker send?

Flag

A valid, unexpired access token with scope: "orders:read" calls DELETE /orders/7, which needs orders:write. What should the API answer?

Flag

Your single-page app gets 401 with error="invalid_token" on GET /me. What should it do?

Flag

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?

Flag

Set-Cookie: sid=9f2c1e; HttpOnly. Which statement is true?

Flag

Fill in the blank

The access token expired. Write the status code and the header a 401 must carry.

Flag
HTTP/1.1  Unauthorized
: Bearer realm="api", error="invalid_token"

The token is fine; the user just isn't an admin.

Flag
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="api", error="", scope="admin"

Reject the token from its expiry second on. exp is a NumericDate.

Flag
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.

Flag
Set-Cookie: refresh_token=pi1B33Pk; ; Secure; SameSite=Strict; =/auth; Max-Age=1209600

With jose v6, pin the accepted algorithm and the intended audience.

Flag
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)));
Flag
const segment = "eyJzdWIiOiJ1XzEiLCJuYW1lIjoiWm_DqyJ9";
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
console.log(JSON.parse(atob(base64)).name);
Flag
// 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");
Flag

Challenge

Fix the bearer-token check in front of the orders API

Flag

authenticate() 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 HS256 tokens signed with options.secret. The token's own header is written by whoever sent it, so it must not decide how — or whether — the token is checked.
  • exp is a NumericDate: seconds since 1970. The token is invalid from that second on. options.nowSeconds is the clock. Access tokens without an exp are not accepted.
  • No credentials at all → 401 with exactly Bearer realm="api" (no error).
  • Credentials that don't check out → 401 with Bearer realm="api", error="invalid_token".
  • A genuine token without the needed scope → 403 with Bearer realm="api", error="insufficient_scope", scope="<the scope>".
  • The scheme name Bearer is 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 HS256 token with the required scope is accepted, with userId from sub
  • No credentials → 401 with WWW-Authenticate: Bearer realm="api" and no error code
  • The Bearer scheme is matched case-insensitively
  • A token that isn't a correctly signed HS256 token — edited payload, wrong secret, alg: none, malformed — gets 401 with error="invalid_token"
  • A token is rejected from its exp second on (exp is in seconds), and a token with no exp is rejected
  • A valid token without the required scope gets 403 with error="insufficient_scope", scope="…"

Loading the in-browser editor…

Further reading