LEVEL 10 · LESSON 2 OF 6

Identity Core

OAuth 2.0 and OpenID Connect

Implement the OAuth code flow with PKCE and OpenID Connect against your own small authorization server, then attack it with forged states and stolen codes.

  • 60 min to read and try
  • You need: Authentication, and Cryptography with node:crypto
  • You build: A working authorization server and client in Node.js, with real PKCE S256 maths, state and nonce checks, a code-for-token exchange, RS256 ID tokens verified through a JWKS endpoint, and scoped access tokens
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Name the four OAuth roles and explain what each token (authorization code, access token, refresh token, ID token) is for
  • Compute and verify a PKCE S256 code challenge, and explain what it stops
  • Implement the authorization code flow with state, nonce and exact redirect URI matching
  • Verify an OpenID Connect ID token (signature via JWKS, issuer, audience, expiry, nonce)
  • Enforce scopes at the resource server
  • Recognise the common OAuth mistakes and the OAuth 2.1 rules that remove them

"Give us your bank password"

A budgeting app wants to show Ada her spending. It needs her bank transactions. The old way was blunt: the app asked for Ada's online-banking password, logged in as her, and read the pages. Think about what that gives away:

  • The app can do anything Ada can: transfer money, change her address, read her messages. It only needed to read transactions.
  • Ada cannot take the access back without changing her password, which also breaks every other app she gave it to.
  • The app now stores her bank password. If the app is breached, so is her bank account.
  • The bank cannot tell Ada from the app, so it cannot protect her with, for example, a second factor on log-in.

OAuth 2.0 is the standard answer. Ada signs in at the bank, on the bank's own page, and approves a narrow request: "Budget App wants to read your transactions". The bank then gives the app a token that allows only that, can expire, and can be revoked on its own. The app never sees the password.

OAuth is about authorization: what an app may do. Log-in with Google or GitHub is authentication: who the user is. OpenID Connect (OIDC) is a thin layer on top of OAuth that adds exactly that, as a signed ID token. This lesson builds both, with no library: a small authorization server and a client, in Node.js, and then attacks them. Sign in with OAuth later uses @zudojs/auth-oauth for the client side; after this lesson you will know what every step of it checks.

Roles, tokens and the flow

OAuth names four roles:

  • the resource owner: Ada, who owns the data;
  • the client: the Budget App, which wants access. A confidential client runs on a server and can keep a client secret; a public client (a mobile or single-page app) cannot, because anyone can read its code;
  • the authorization server: the bank's log-in service, which authenticates Ada, asks for her consent and issues tokens;
  • the resource server: the bank's API, which accepts tokens and serves data.

What the client asks for is expressed as scopes, space-separated names such as openid email transactions:read. And there are four kinds of credential, each with one job:

CredentialWho receives itWhat it is forLifetime
Authorization codeThe client, through the browserA one-time ticket to exchange for tokensAbout a minute, single use
Access tokenThe client, server to serverCalling the resource server's API, within its scopesMinutes to an hour
Refresh tokenThe client, server to serverGetting new access tokens without asking Ada againDays to months, revocable
ID token (OIDC)The clientTelling the client who logged in; never sent to APIsMinutes; checked once at log-in

The authorization code flow with PKCE is the one flow you should use for users, whatever the client type:

 Ada's browser            Budget App (client)             Bank (authorization server + API)
      │  click "Connect bank"  │                                     │
      │ ─────────────────────► │ make state, nonce, code_verifier    │
      │ ◄───── 302 to /authorize?client_id&redirect_uri&scope       │
      │        &state&nonce&code_challenge                           │
      │ ───────────────────────────────────────────────────────────► │ Ada signs in, consents
      │ ◄───── 302 to https://budget.example/callback?code&state ─── │
      │ ─────────────────────► │ check state                         │
      │                        │ POST /token: code + code_verifier   │
      │                        │ + client secret  ─────────────────► │ check code, PKCE, secret
      │                        │ ◄──── access_token, id_token ────── │
      │                        │ verify ID token (JWKS, aud, nonce)  │
      │                        │ GET /api/transactions  ───────────► │ check token and scope
The code travels through the browser; the tokens travel only between servers.

What can go wrong

REASON IT OUT

Before you trust a callback

The callback URL, https://budget.example/callback?code=…&state=…, is public. Before writing the client, think it through:

  • Who can make a browser open that URL, with any code they like?
  • The code sits in a URL. Where can a URL leak?
  • What stops someone who has a leaked code from exchanging it for tokens?
  • What if an attacker changes redirect_uri in the authorization URL to their own site?
  • The ID token says "this is Ada". What stops an attacker from giving the Budget App an ID token that some other app received for Ada, or an old one?
Show the reasoning

Anyone. An attacker can sign in at the bank as themselves, stop before the callback, and trick Ada's browser into opening the callback with the attacker's code. If the app accepts it, Ada's session is now linked to the attacker's bank account, and whatever she saves is theirs (login CSRF). The state parameter stops this: a random value the app created for this browser's log-in and stored on the server, which must come back unchanged.

URLs leak through browser history, proxy and server logs, the Referer header, and malicious apps on phones that register the same URL scheme. So a code must be short-lived and single-use, and holding it must not be enough.

PKCE (Proof Key for Code Exchange): the app sends a hash of a random secret in the first step and the secret itself in the exchange. A thief has the code but not the secret. A confidential client also authenticates with its client secret.

The authorization server must compare redirect_uri exactly against the values registered for the client, and refuse (without redirecting) when it does not match. Otherwise it would hand codes to the attacker's site.

The ID token must be verified: signed by the provider (checked with the provider's published keys), iss is the provider, aud is this client's id, not expired, and its nonce equals the random value this log-in sent. A token for another app fails aud; an old or replayed one fails exp or nonce.

PKCE, by hand

PKCE is defined in RFC 7636. The client creates a code verifier: 43 to 128 characters from the "unreserved" set (letters, digits, -, ., _, ~). It sends the code challenge in the authorization request:

code_challenge = BASE64URL( SHA-256( code_verifier ) ), with code_challenge_method = S256

At the exchange it sends the verifier itself, and the server recomputes the hash and compares. Because SHA-256 cannot be reversed, seeing the challenge in a URL does not reveal the verifier. Public clients such as single-page apps compute this in the browser, so this example uses Web Crypto, which works in both the browser and Node.js. It checks the example pair printed in the RFC itself:

pkce.js
function base64url(bytes) {
  return btoa(String.fromCharCode(...new Uint8Array(bytes))).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}

async function challengeFor(verifier) {
  return base64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
}

const rfcVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
console.log("computed: ", await challengeFor(rfcVerifier));
console.log("RFC 7636: ", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");

const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const valid = /^[A-Za-z0-9\-._~]{43,128}$/.test(verifier);
const challenge = await challengeFor(verifier);
console.log("new verifier:", verifier.length, "chars, valid:", valid, "| challenge:", challenge.length, "chars");
console.log("challenge reveals verifier:", challenge.includes(verifier));
Output of node pkce.js and of the browser terminal
computed:  E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
RFC 7636:  E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
new verifier: 43 chars, valid: true | challenge: 43 chars
challenge reveals verifier: false

32 random bytes encode to exactly 43 base64url characters, the minimum length, with 256 bits of randomness. The RFC also defines a plain method where the challenge is the verifier. It protects nothing against anyone who can read the URL, so servers should refuse it; the OAuth 2.1 draft requires S256.

A small authorization server

To see every check from the server's side, here is a complete, tiny authorization server and resource server in one node:http process: the bank. It is for learning and testing only, but each rule in it is one that real servers apply:

  • GET /authorize: validates the client and its exactly registered redirect URI, requires a PKCE S256 challenge, checks the scopes, and (Ada having already signed in at the bank, and consented) redirects back with a code. The code remembers the client, the redirect URI, the challenge, the scopes, the nonce and the user.
  • POST /token: authenticates the client with its secret, redeems a code once, checks the redirect URI and the PKCE verifier, and returns an access token and, for openid, an ID token signed with RS256.
  • GET /jwks: the public key that verifies ID tokens, as a JSON Web Key Set.
  • GET /api/transactions and POST /api/transfers: the bank's API, which checks the access token and its scopes.
bank.js
import http from "node:http";
import { createHash, generateKeyPairSync, randomBytes, sign, timingSafeEqual } from "node:crypto";

export const ISSUER = "https://id.bank.example";
const KID = "bank-key-2026-09";
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const sha256 = (text) => createHash("sha256").update(text).digest();
const random = () => randomBytes(32).toString("base64url");
const b64 = (object) => Buffer.from(JSON.stringify(object)).toString("base64url");

const CLIENTS = {
  "budget-app": { secret: "budget-app-secret-0123456789", redirectUris: ["https://budget.example/callback"], scopes: ["openid", "email", "transactions:read"] },
  "quiz-app": { secret: "quiz-app-secret-0123456789", redirectUris: ["https://quiz.example/callback"], scopes: ["openid", "email"] },
};
const USERS = { ada: { sub: "248289761001", email: "ada@example.com", email_verified: true } };
const codes = new Map();
const accessTokens = new Map();

function idToken(claims) {
  const signingInput = `${b64({ alg: "RS256", typ: "JWT", kid: KID })}.${b64(claims)}`;
  return `${signingInput}.${sign("sha256", Buffer.from(signingInput), privateKey).toString("base64url")}`;
}

function authorize(params, userName) {
  const clientId = params.get("client_id");
  const client = CLIENTS[clientId];
  const redirectUri = params.get("redirect_uri");
  if (!client || !client.redirectUris.includes(redirectUri)) {
    return { status: 400, body: { error: "invalid_request", error_description: "unknown client_id or redirect_uri" } };
  }
  const back = new URL(redirectUri);
  const reply = (name, value) => {
    back.searchParams.set(name, value);
    if (params.has("state")) back.searchParams.set("state", params.get("state"));
    return { status: 302, location: back.href };
  };
  const scopes = (params.get("scope") ?? "").split(" ");
  const challenge = params.get("code_challenge") ?? "";
  if (params.get("response_type") !== "code") return reply("error", "unsupported_response_type");
  if (params.get("code_challenge_method") !== "S256" || !/^[\w-]{43}$/.test(challenge)) return reply("error", "invalid_request");
  if (!scopes.every((scope) => client.scopes.includes(scope))) return reply("error", "invalid_scope");
  const user = USERS[userName];
  if (!user) return reply("error", "login_required");
  const code = random();
  codes.set(code, { clientId, redirectUri, challenge, scopes, nonce: params.get("nonce"), user, expiresAt: Date.now() + 60_000, used: false });
  return reply("code", code);
}

function token(form) {
  const clientId = form.get("client_id");
  const client = CLIENTS[clientId];
  if (!client || !timingSafeEqual(sha256(form.get("client_secret") ?? ""), sha256(client.secret))) {
    return { status: 401, body: { error: "invalid_client" } };
  }
  const code = form.get("code") ?? "";
  const grant = codes.get(code);
  if (!grant || grant.clientId !== clientId || grant.redirectUri !== form.get("redirect_uri") || grant.expiresAt < Date.now()) {
    return { status: 400, body: { error: "invalid_grant" } };
  }
  if (grant.used) {
    for (const [value, info] of accessTokens) if (info.code === code) accessTokens.delete(value);
    return { status: 400, body: { error: "invalid_grant" } };
  }
  if (sha256(form.get("code_verifier") ?? "").toString("base64url") !== grant.challenge) {
    return { status: 400, body: { error: "invalid_grant" } };
  }
  grant.used = true;
  const accessToken = random();
  accessTokens.set(accessToken, { sub: grant.user.sub, scopes: grant.scopes, code, expiresAt: Date.now() + 600_000 });
  const body = { access_token: accessToken, token_type: "Bearer", expires_in: 600, scope: grant.scopes.join(" ") };
  if (grant.scopes.includes("openid")) {
    const iat = Math.floor(Date.now() / 1000);
    body.id_token = idToken({ iss: ISSUER, sub: grant.user.sub, aud: clientId, iat, exp: iat + 600, nonce: grant.nonce, email: grant.user.email, email_verified: grant.user.email_verified });
  }
  return { status: 200, body };
}

function api(method, authorization) {
  const info = accessTokens.get((authorization ?? "").replace(/^Bearer /, ""));
  if (!info || info.expiresAt < Date.now()) return { status: 401, headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' } };
  const needed = method === "GET" ? "transactions:read" : "transactions:write";
  if (!info.scopes.includes(needed)) {
    return { status: 403, headers: { "WWW-Authenticate": `Bearer error="insufficient_scope", scope="${needed}"` } };
  }
  return { status: 200, body: [{ date: "2026-09-20", description: "Shoprite Lekki", amountKobo: -1850000 }, { date: "2026-09-22", description: "Salary", amountKobo: 45000000 }] };
}

export function startBank() {
  const server = http.createServer(async (req, res) => {
    const url = new URL(req.url, "http://localhost");
    let text = "";
    for await (const chunk of req) text += chunk;
    const userName = (req.headers.cookie ?? "").match(/provider_session=(\w+)/)?.[1];
    let result = { status: 404 };
    if (url.pathname === "/authorize") result = authorize(url.searchParams, userName);
    if (url.pathname === "/token" && req.method === "POST") result = token(new URLSearchParams(text));
    if (url.pathname === "/jwks") result = { status: 200, body: { keys: [{ ...publicKey.export({ format: "jwk" }), kid: KID, alg: "RS256", use: "sig" }] } };
    if (url.pathname.startsWith("/api/")) result = api(req.method, req.headers.authorization);
    res.writeHead(result.status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...result.headers, ...(result.location && { Location: result.location }) });
    res.end(result.body ? JSON.stringify(result.body) : undefined);
  });
  return new Promise((resolve) => {
    server.listen(0, () => resolve({ base: `http://localhost:${server.address().port}`, close: () => server.close() }));
  });
}

A few details worth noticing before moving on:

  • An unknown client or a redirect URI that is not registered gets a 400 on the bank's own page. Every other error is sent back to the (verified) redirect URI with error=… and the state, so the client can show a message.
  • If a code is presented a second time, the server refuses it and also revokes the access tokens issued from it: someone besides the client has the code, and the tokens may be in the wrong hands.
  • The token response has Cache-Control: no-store, so no proxy or browser cache keeps a copy of the tokens.
  • The resource server answers a missing or expired token with 401 and a token with the wrong scope with 403, each with a WWW-Authenticate header naming the error.

The client: the Budget App

The client has two steps. startLogin creates three random values (state, nonce and the PKCE verifier), keeps them on the server for this browser, and builds the authorization URL. finishLogin handles the callback: it checks the state, exchanges the code with the verifier and the client secret, and verifies the ID token with the bank's public key from the JWKS endpoint:

budget-app.js
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto";

export const BUDGET_APP = {
  clientId: "budget-app",
  clientSecret: "budget-app-secret-0123456789",
  redirectUri: "https://budget.example/callback",
  issuer: "https://id.bank.example",
};

const random = () => randomBytes(32).toString("base64url");
const decode = (part) => JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
const sameSecret = (a, b) => typeof a === "string" && typeof b === "string" && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));

export function startLogin(bankBase, app = BUDGET_APP, scope = "openid email transactions:read") {
  const pending = { state: random(), nonce: random(), verifier: random() };
  const url = new URL("/authorize", bankBase);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: app.clientId,
    redirect_uri: app.redirectUri,
    scope,
    state: pending.state,
    nonce: pending.nonce,
    code_challenge: createHash("sha256").update(pending.verifier).digest("base64url"),
    code_challenge_method: "S256",
  });
  return { url: url.href, pending };
}

export async function verifyIdToken(bankBase, app, idToken, expectedNonce) {
  const [header, payload, signature] = String(idToken).split(".");
  const { alg, kid } = decode(header);
  if (alg !== "RS256") throw new Error(`ID token algorithm ${alg} not allowed`);
  const { keys } = await (await fetch(new URL("/jwks", bankBase))).json();
  const jwk = keys.find((key) => key.kid === kid);
  if (!jwk) throw new Error("ID token signed with an unknown key");
  const key = createPublicKey({ key: jwk, format: "jwk" });
  if (!verify("sha256", Buffer.from(`${header}.${payload}`), key, Buffer.from(signature, "base64url"))) {
    throw new Error("ID token signature is invalid");
  }
  const claims = decode(payload);
  if (claims.iss !== app.issuer) throw new Error("ID token from the wrong issuer");
  if (claims.aud !== app.clientId) throw new Error("ID token was issued to another client");
  if (claims.exp < Date.now() / 1000) throw new Error("ID token expired");
  if (!sameSecret(claims.nonce, expectedNonce)) throw new Error("ID token nonce does not match this log-in");
  return claims;
}

export async function finishLogin(bankBase, callbackUrl, pending, app = BUDGET_APP) {
  const params = new URL(callbackUrl).searchParams;
  if (!sameSecret(params.get("state"), pending?.state)) throw new Error("state mismatch: not a log-in this browser started");
  if (params.has("error")) throw new Error(`the bank refused: ${params.get("error")}`);
  const response = await fetch(new URL("/token", bankBase), {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: params.get("code") ?? "",
      redirect_uri: app.redirectUri,
      client_id: app.clientId,
      client_secret: app.clientSecret,
      code_verifier: pending.verifier,
    }),
  });
  const tokens = await response.json();
  if (!response.ok) throw new Error(`token exchange failed: ${tokens.error}`);
  const claims = await verifyIdToken(bankBase, app, tokens.id_token, pending.nonce);
  return { account: `${claims.iss}|${claims.sub}`, email: claims.email, accessToken: tokens.access_token, scope: tokens.scope, idToken: tokens.id_token };
}

Two choices in finishLogin matter more than they look:

  • The state is checked first, even before an error in the callback is believed: a forged callback must not even produce an error message of the attacker's choosing.
  • The local account is keyed by iss plus sub (subject), the provider's permanent, unique id for the user. Not by e-mail: e-mail addresses change, get recycled, and at some providers are not verified.

The examples play the browser with a helper. Ada has already signed in at the bank (a cookie at the bank's domain), so /authorize redirects straight back; the helper returns the Location it was sent to, or the error page:

browser.js
export async function openInBrowser(url, bankUser = "ada") {
  const response = await fetch(url, { redirect: "manual", headers: { Cookie: `provider_session=${bankUser}` } });
  if (response.status === 302) return response.headers.get("location");
  return `error page ${response.status}: ${(await response.json()).error_description}`;
}

The whole flow

Now run it end to end: start, sign in at the bank, come back, exchange, verify, and call the API. Random values change on every run, so the example prints their names and lengths instead of their values:

happy-path.jsNode.js only
import { startBank } from "./bank.js";
import { finishLogin, startLogin } from "./budget-app.js";
import { openInBrowser } from "./browser.js";

const bank = await startBank();

const { url, pending } = startLogin(bank.base);
const request = new URL(url);
console.log("1. to the bank:", [...request.searchParams.keys()].join(", "));

const callback = await openInBrowser(url);
console.log("2. back at:", new URL(callback).origin + new URL(callback).pathname, "with", [...new URL(callback).searchParams.keys()].join(", "));

const login = await finishLogin(bank.base, callback, pending);
console.log("3. logged in:", login.account, login.email, "| scope:", login.scope);
console.log("   access token:", login.accessToken.length, "chars, opaque; ID token parts:", login.idToken.split(".").length);

const api = (method, path) => fetch(bank.base + path, { method, headers: { Authorization: `Bearer ${login.accessToken}` } });
const read = await api("GET", "/api/transactions");
console.log("4. GET transactions:", read.status, (await read.json()).map((t) => `${t.description} ${t.amountKobo / 100}`));
const write = await api("POST", "/api/transfers");
console.log("5. POST transfer:", write.status, write.headers.get("www-authenticate"));
bank.close();
Output of node happy-path.js
1. to the bank: response_type, client_id, redirect_uri, scope, state, nonce, code_challenge, code_challenge_method
2. back at: https://budget.example/callback with code, state
3. logged in: https://id.bank.example|248289761001 ada@example.com | scope: openid email transactions:read
   access token: 43 chars, opaque; ID token parts: 3
4. GET transactions: 200 [ 'Shoprite Lekki -18500', 'Salary 450000' ]
5. POST transfer: 403 Bearer error="insufficient_scope", scope="transactions:write"
  • The authorization URL carries everything the bank needs, and nothing secret: the challenge, not the verifier; the client id, not the secret.
  • The callback carries only the code and the state.
  • Ada's account at the Budget App is issuer|sub. Her e-mail is a display detail.
  • The access token read transactions, and was refused for a transfer with 403 insufficient_scope. That is the point of OAuth: the Budget App got exactly the access Ada approved, and nothing more.

Inside the ID token and the key set

The ID token is a JWT like the one you built in Authentication, with one difference: it is signed with RS256, an RSA private key that only the bank holds. Anyone can verify it with the matching public key, which the bank publishes at its JWKS endpoint. That is what lets thousands of client apps verify the bank's tokens without sharing a secret with it:

inside-id-token.jsNode.js only
import { startBank } from "./bank.js";
import { finishLogin, startLogin } from "./budget-app.js";
import { openInBrowser } from "./browser.js";

const bank = await startBank();
const { url, pending } = startLogin(bank.base);
const { idToken } = await finishLogin(bank.base, await openInBrowser(url), pending);

const [header, payload] = idToken.split(".").slice(0, 2).map((part) => JSON.parse(Buffer.from(part, "base64url").toString()));
console.log("header:", header);
console.log("claims:", Object.keys(payload).join(", "));
console.log("nonce is this log-in's:", payload.nonce === pending.nonce, "| lifetime:", payload.exp - payload.iat, "s");

const { keys } = await (await fetch(`${bank.base}/jwks`)).json();
const { n, ...rest } = keys[0];
console.log("published key:", rest, "| modulus:", Buffer.from(n, "base64url").length * 8, "bits");
bank.close();
Output of node inside-id-token.js
header: { alg: 'RS256', typ: 'JWT', kid: 'bank-key-2026-09' }
claims: iss, sub, aud, iat, exp, nonce, email, email_verified
nonce is this log-in's: true | lifetime: 600 s
published key: {
  kty: 'RSA',
  e: 'AQAB',
  kid: 'bank-key-2026-09',
  alg: 'RS256',
  use: 'sig'
} | modulus: 2048 bits

The header names the algorithm and the key id (kid); the client looks that id up in the key set, which lets the bank rotate keys by publishing the new one before it starts signing with it. The key set contains only the public parts of the key (the modulus n and exponent e), so publishing it gives nothing away.

Attacking the flow

Login CSRF: a callback this browser did not start

The attacker signs in at the bank as themselves, keeps the callback URL, and sends it to Ada. Ada's browser opens it, carrying her pending log-in (her state):

attack-state.jsNode.js only
import { startBank } from "./bank.js";
import { finishLogin, startLogin } from "./budget-app.js";
import { openInBrowser } from "./browser.js";

const bank = await startBank();
const attacker = startLogin(bank.base);
const attackersCallback = await openInBrowser(attacker.url);

const ada = startLogin(bank.base);
for (const [label, callback, pending] of [
  ["attacker's callback in Ada's browser", attackersCallback, ada.pending],
  ["callback with the state removed", attackersCallback.replace(/&state=[^&]+/, ""), ada.pending],
  ["callback, but no pending log-in", attackersCallback, undefined],
]) {
  try {
    await finishLogin(bank.base, callback, pending);
    console.log(label, "-> ACCEPTED");
  } catch (error) {
    console.log(label, "->", error.message);
  }
}
bank.close();
Output of node attack-state.js
attacker's callback in Ada's browser -> state mismatch: not a log-in this browser started
callback with the state removed -> state mismatch: not a log-in this browser started
callback, but no pending log-in -> state mismatch: not a log-in this browser started

All three are refused before the code is even sent to the bank. The pending values live on the Budget App's server, keyed by an HttpOnly cookie, and are deleted once used, exactly like pending.ts in the ZudoJS OAuth lesson. Comparing the state against a value that also comes from the browser (a second cookie the attacker can set) would prove nothing.

A stolen code, and a replayed one

Suppose the code leaks from a log file. The thief knows the Budget App's public details, and in the worst case even its client secret (a public client has none). What they do not have is the verifier:

attack-code.jsNode.js only
import { startBank } from "./bank.js";
import { BUDGET_APP, finishLogin, startLogin } from "./budget-app.js";
import { openInBrowser } from "./browser.js";

const bank = await startBank();
const { url, pending } = startLogin(bank.base);
const callback = await openInBrowser(url);
const code = new URL(callback).searchParams.get("code");

async function exchange(verifier) {
  const response = await fetch(`${bank.base}/token`, {
    method: "POST",
    body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: BUDGET_APP.redirectUri,
      client_id: BUDGET_APP.clientId, client_secret: BUDGET_APP.clientSecret, code_verifier: verifier }),
  });
  return { status: response.status, ...(await response.json()) };
}

console.log("thief, guessed verifier:", (await exchange("a".repeat(43))).error);
const login = await finishLogin(bank.base, callback, pending);
console.log("real app:", "logged in as", login.email);

const replay = await exchange(pending.verifier);
console.log("same code again, even with the verifier:", replay.status, replay.error);
const api = await fetch(`${bank.base}/api/transactions`, { headers: { Authorization: `Bearer ${login.accessToken}` } });
console.log("the first access token after the replay:", api.status, api.headers.get("www-authenticate"));
bank.close();
Output of node attack-code.js
thief, guessed verifier: invalid_grant
real app: logged in as ada@example.com
same code again, even with the verifier: 400 invalid_grant
the first access token after the replay: 401 Bearer error="invalid_token"

The thief's exchange failed: without the verifier, the hash does not match the challenge. The real app's exchange worked. When the same code came back a second time, the bank refused it and revoked the tokens already issued from it, because a second redemption means the code is in two places. The honest app has to start again, which is a small price for cutting off a thief.

Changing the redirect URI

The attacker edits the authorization URL so the code is sent to their own site, or to a URL that merely starts with the registered one:

attack-redirect.jsNode.js only
import { startBank } from "./bank.js";
import { startLogin } from "./budget-app.js";
import { openInBrowser } from "./browser.js";

const bank = await startBank();
const { url } = startLogin(bank.base);

for (const evil of [
  "https://evil.example/steal",
  "https://budget.example/callback/../../redirect?to=https://evil.example",
  "https://budget.example.evil.example/callback",
  "https://budget.example/callback?next=https://evil.example",
]) {
  const tampered = new URL(url);
  tampered.searchParams.set("redirect_uri", evil);
  console.log(await openInBrowser(tampered.href));
}
console.log(new URL(await openInBrowser(url)).host, "(the registered URI still works)");
bank.close();
Output of node attack-redirect.js
error page 400: unknown client_id or redirect_uri
error page 400: unknown client_id or redirect_uri
error page 400: unknown client_id or redirect_uri
error page 400: unknown client_id or redirect_uri
budget.example (the registered URI still works)

Every variant got the bank's own error page, and no redirect: an exact string comparison against the registered list has no clever edge cases. Servers that allowed "any URL starting with" or wildcard subdomains have leaked codes in real incidents. On the client side, the same care applies to the page you send Ada to after log-in: a ?next= parameter must be checked against your own paths, or your callback becomes an open redirect.

An ID token meant for someone else

Now a subtler attack. Ada also uses Quiz App, a harmless app that signs users in with the same bank. Quiz App (or someone who breached it) holds a valid, correctly signed ID token for Ada. Can it be used to log in to the Budget App as Ada? And what about a genuine Budget App ID token from an earlier log-in, replayed now?

attack-id-token.jsNode.js only
import { startBank } from "./bank.js";
import { BUDGET_APP, finishLogin, startLogin, verifyIdToken } from "./budget-app.js";
import { openInBrowser } from "./browser.js";

const bank = await startBank();
const QUIZ_APP = { clientId: "quiz-app", clientSecret: "quiz-app-secret-0123456789", redirectUri: "https://quiz.example/callback", issuer: "https://id.bank.example" };

const quiz = startLogin(bank.base, QUIZ_APP, "openid email");
const quizLogin = await finishLogin(bank.base, await openInBrowser(quiz.url), quiz.pending, QUIZ_APP);
const [, payload] = quizLogin.idToken.split(".");
const claims = JSON.parse(Buffer.from(payload, "base64url").toString());
console.log("Quiz App's ID token for Ada: aud =", claims.aud, "| sub =", claims.sub);

const budget = startLogin(bank.base);
const tries = {
  "Quiz App's token at Budget App": [quizLogin.idToken, budget.pending.nonce],
  "old Budget App token, new log-in": [(await finishLogin(bank.base, await openInBrowser(budget.url), budget.pending)).idToken, startLogin(bank.base).pending.nonce],
  "token with a changed payload": [quizLogin.idToken.replace(payload, Buffer.from(JSON.stringify({ ...claims, aud: "budget-app" })).toString("base64url")), budget.pending.nonce],
};
for (const [label, [token, nonce]] of Object.entries(tries)) {
  try {
    await verifyIdToken(bank.base, BUDGET_APP, token, nonce);
    console.log(label.padEnd(34), "-> ACCEPTED");
  } catch (error) {
    console.log(label.padEnd(34), "->", error.message);
  }
}
bank.close();
Output of node attack-id-token.js
Quiz App's ID token for Ada: aud = quiz-app | sub = 248289761001
Quiz App's token at Budget App     -> ID token was issued to another client
old Budget App token, new log-in   -> ID token nonce does not match this log-in
token with a changed payload       -> ID token signature is invalid

Each check stops a different attack. The audience check rejects a real token issued to another app. The nonce check rejects a real Budget App token that belongs to a different log-in. The signature check rejects a token whose audience was edited. Skip any one of them and there is an attack that gets through.

THE ACCESS TOKEN IS NOT A LOG-IN

A tempting shortcut: "the user gave us an access token, we called the provider's profile endpoint with it, it returned Ada, so this is Ada". But an access token says nothing about which client it was issued to. Quiz App could call your API with Ada's token from Quiz App and be treated as Ada. To learn who logged in to your app, use the ID token, verified with aud equal to your client id, or run the whole code flow yourself.

Common mistakes, and OAuth 2.1

MistakeWhat goes wrongInstead
Implicit flow (response_type=token)Access tokens come back in the URL fragment: browser history, extensions and logs see them; no way to bind them to the clientAuthorization code flow with PKCE, even in single-page apps
Resource owner password grantThe app collects the user's password: the exact problem OAuth was made to solveRedirect to the authorization server
No state, or a state compared with a cookie the browser also controlsLogin CSRFRandom state stored server-side per log-in, single use
No PKCE, or plainA leaked code is enough to get tokensPKCE S256 for every client
Prefix or wildcard redirect URI matchingCodes delivered to attacker-controlled URLsExact matching against registered URIs
Not verifying the ID token (or skipping aud, nonce)Tokens from other apps or old log-ins acceptedSignature via JWKS, iss, aud, exp, nonce
Using the access token as proof of identityToken substitution between appsUse the verified ID token
Linking accounts by e-mailAn unverified or recycled e-mail takes over an existing accountKey accounts by iss + sub; link by e-mail only after the user proves both accounts
Tokens in localStorage, in logs, or in URLsStolen by XSS, read from logsKeep tokens server-side (a "backend for frontend"), log token ids at most
Asking for every scope "just in case"Users refuse, and a breach exposes far moreLeast privilege: ask for what the feature needs, when it needs it

OAuth 2.1, the consolidation of OAuth 2.0 and a decade of security advice, turns most of this table into rules. It is still an IETF Internet-Draft, not yet a published RFC, but providers already follow it, and the OAuth 2.0 Security Best Current Practice (RFC 9700) says the same things: PKCE is required for all clients, redirect URIs must match exactly, and the implicit and password grants are gone. If you follow this lesson, you are already writing OAuth 2.1.

Testing and production concerns

  • Use a certified library for the client, and a real product for the server. OpenID Connect has certified implementations (for Node.js, openid-client), and running your own authorization server is a product in itself. This lesson's server exists to show the rules, not to be deployed.
  • Discovery. Real providers publish their endpoints and settings at /.well-known/openid-configuration: the authorization and token URLs, the jwks_uri, supported scopes and algorithms. Clients read it instead of hard-coding URLs.
  • Cache the JWKS, and refresh on an unknown kid. Providers rotate signing keys; fetching the key set on every log-in is slow, and never refreshing it breaks on rotation. Fetch again when a token names a key you do not have (at most every few minutes).
  • Test offline with a stand-in provider, as this lesson and the ZudoJS OAuth lesson do, including every attack above as a test case. Then test once against the real provider in a staging environment.
  • After the flow, your own session. OAuth ends with verified identity; what follows is the log-in from Authentication: a fresh server-side session, a __Host- cookie, idle and absolute expiry. Store refresh tokens encrypted, server-side only, and revoke them when the user disconnects the bank.
  • Secrets from the environment. The client secret is a password for your app. Keep it out of code and git, and rotate it if it leaks, as with every secret in Cryptography for developers.

Practice

TRY IT YOURSELF

Validate a code verifier

A server receives code_verifier values. Write checkVerifier(v) that returns "ok" only for 43 to 128 characters from the unreserved set, and a reason otherwise. Test a good verifier, one of 42 characters, one with a space, and one of 129 characters.

Show a solution
check-verifier.js
function checkVerifier(v) {
  if (typeof v !== "string") return "missing";
  if (v.length < 43) return "too short";
  if (v.length > 128) return "too long";
  if (!/^[A-Za-z0-9\-._~]+$/.test(v)) return "characters outside the unreserved set";
  return "ok";
}

for (const v of ["dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "x".repeat(42), "correct horse battery staple and more words", "y".repeat(129)]) {
  console.log(String(v.length).padStart(3), checkVerifier(v));
}
Output of node check-verifier.js and of the browser terminal
 43 ok
 42 too short
 43 characters outside the unreserved set
129 too long

The minimum length is what gives a verifier its strength: 43 characters from 66 possible symbols is more than 256 bits when generated randomly. A human-chosen phrase with spaces is refused outright, which also stops a client from using something guessable.

TRY IT YOURSELF

Exact redirect URI matching

Write two matchers for registered redirect URIs: prefixMatch (the broken kind: the requested URI starts with a registered one) and exactMatch. Show which of these requests each accepts: the registered URI, https://budget.example/callbackevil, https://budget.example/callback/../admin and https://budget.example.evil.example/callback (registered: https://budget.example/callback).

Show a solution
redirect-match.js
const registered = ["https://budget.example/callback"];
const prefixMatch = (uri) => registered.some((r) => uri.startsWith(r));
const exactMatch = (uri) => registered.includes(uri);

for (const uri of [
  "https://budget.example/callback",
  "https://budget.example/callbackevil",
  "https://budget.example/callback/../admin",
  "https://budget.example.evil.example/callback",
]) {
  console.log(prefixMatch(uri) ? "prefix: yes" : "prefix: no ", exactMatch(uri) ? "| exact: yes" : "| exact: no ", uri);
}
Output of node redirect-match.js and of the browser terminal
prefix: yes | exact: yes https://budget.example/callback
prefix: yes | exact: no  https://budget.example/callbackevil
prefix: yes | exact: no  https://budget.example/callback/../admin
prefix: no  | exact: no  https://budget.example.evil.example/callback

The prefix matcher accepts two attacker-shaped URIs; the exact matcher accepts only the registered one. (The last one is refused by both here, but a matcher that compared only the end of the host, or allowed wildcards such as https://*.budget.example, would accept a look-alike.) Exact comparison is simple, and simple is what you want in a security check.

TRY IT YOURSELF

A safe return path after log-in

The Budget App's log-in link is /login?next=/reports/september, and after the callback it sends the user to next. Write safeNext(next) that allows only paths on the app itself and falls back to /. Test /reports/september, https://evil.example, //evil.example, /\evil.example and javascript:alert(1).

Show a solution
safe-next.js
function safeNext(next) {
  if (typeof next !== "string" || !next.startsWith("/") || next.startsWith("//") || next.includes("\\")) return "/";
  const url = new URL(next, "https://budget.example");
  return url.origin === "https://budget.example" ? url.pathname + url.search : "/";
}

for (const next of ["/reports/september", "https://evil.example", "//evil.example", "/\\evil.example", "javascript:alert(1)"]) {
  console.log(next.padEnd(20), "->", safeNext(next));
}
Output of node safe-next.js and of the browser terminal
/reports/september   -> /reports/september
https://evil.example -> /
//evil.example       -> /
/\evil.example       -> /
javascript:alert(1)  -> /

//evil.example is a protocol-relative URL: browsers read it as another site. Some browsers treat a backslash like a slash, which makes /\evil.example the same trick. Resolving against your own origin and comparing origins catches whatever the string checks miss.

Summary

  • OAuth 2.0 lets a user grant an app limited, revocable access (scopes) without sharing a password. OpenID Connect adds a signed ID token that tells the app who logged in.
  • The authorization code flow sends a short-lived, single-use code through the browser and exchanges it server to server for tokens. Use it with PKCE for every client.
  • PKCE: code_challenge = BASE64URL(SHA-256(code_verifier)), method S256; the verifier is sent only at the exchange, so a leaked code is useless.
  • state binds the callback to the browser that started the log-in (stopping login CSRF); nonce binds the ID token to that log-in; both are random, stored server-side, and single use.
  • Authorization servers match redirect URIs exactly, redeem codes once (revoking tokens on reuse), and resource servers enforce scopes with 401 and 403.
  • Verify ID tokens completely: signature with the provider's JWKS key, iss, aud, exp, nonce. Key accounts by iss + sub. Never treat an access token as a log-in.
  • The OAuth 2.1 draft makes this the only way: PKCE everywhere, exact redirect URIs, no implicit or password grants.

Next: Browser attacks and defences: CSRF, CORS, XSS and the security headers that protect the cookies and tokens from these two lessons.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.