Crux Read real OAuth/OIDC code — PKCE exchange, id_token validation, refresh rotation, and DPoP binding — spot the mandatory check that is missing or inverted, and pick the fix.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
Every OAuth CVE is one missing or inverted check, hiding in code that looks correct. Read each snippet the way a security reviewer would, find the skipped mandatory step, and choose the fix that closes the actual hole.
Goal
Practise the review loop that catches OAuth bugs before they ship: read the exchange or validation path, identify which mandatory check is absent or wrong, and reach for the precise fix rather than a vague hardening pass.
Snippet 1 — the token exchange
// callback handler after the IdP redirects back with ?code=...&state=...async function handleCallback(req, session) { const { code, state } = req.query; const res = await fetch(TOKEN_ENDPOINT, { method: "POST", body: new URLSearchParams({ grant_type: "authorization_code", code, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: session.codeVerifier, }), }); return res.json(); // tokens}
Quiz
Completed
The PKCE code_verifier is sent correctly. What mandatory check is missing in this callback, and what does it let an attacker do?
Heads-up PKCE binds the code to this client; it does not prove this callback belongs to the flow this user started. State is the independent anti-CSRF check, and it is absent here.
Heads-up The /token request must carry the original code_verifier so the server can hash and compare it to the stored challenge. Sending the challenge here would break PKCE entirely.
Heads-up The redirect_uri must be present and exactly match the registered value so the server can confirm it; omitting it weakens the flow rather than fixing the missing state check.
Snippet 2 — id_token validation
import jwt from "jsonwebtoken";function verifyIdToken(idToken, jwks) { const header = decodeHeader(idToken); const key = jwks.find((k) => k.kid === header.kid); // verify using whatever the token says it used const claims = jwt.verify(idToken, key.pem, { algorithms: [header.alg] }); if (claims.iss !== EXPECTED_ISS) throw new Error("bad iss"); if (claims.exp < Date.now() / 1000) throw new Error("expired"); return claims;}
Quiz
Completed
This validator checks iss and exp. Which two mandatory checks are broken or missing, and why do they matter?
Heads-up Dividing Date.now() by 1000 already converts to seconds to match exp. The real defects are trusting header.alg and skipping aud and nonce.
Heads-up Selecting the key by kid is correct and required. The defects are algorithm trust and the missing aud and nonce checks, not the key lookup.
Heads-up An attacker crafts the token, not the IdP. By trusting header.alg the validator can be steered to alg=none or HS256 with the public key as the secret, regardless of what the IdP normally uses.
Snippet 3 — refresh rotation
async function refresh(presentedRT) { const record = await db.refreshTokens.find(presentedRT); if (!record) throw new Error("unknown refresh token"); // issue a fresh pair const next = await mintTokens(record.userId); await db.refreshTokens.insert(next.refreshToken, { userId: record.userId }); return next;}
Quiz
Completed
This issues a new refresh token on every call. Why does it fail to detect a stolen refresh token, and what is the fix?
Heads-up Reusing the same long-lived refresh token is exactly what rotation replaces. The fix is to invalidate the old token and detect its reuse, not to stop issuing new ones.
Heads-up Refresh uses the refresh token, not the access token. The defect is the absent used-marking and replay detection on the refresh token itself.
Heads-up A shorter TTL bounds the window but still lets a stolen token work until expiry undetected. Rotation with replay detection is what surfaces the theft.
Snippet 4 — DPoP proof verification
function verifyDpop(proofJwt, accessToken, req) { const { header, payload } = parse(proofJwt); verifySignature(proofJwt, header.jwk); // signed by embedded key if (payload.htm !== req.method) throw new Error("htm"); if (payload.htu !== req.url) throw new Error("htu"); if (Math.abs(now() - payload.iat) > 60) throw new Error("iat skew"); return true;}
Quiz
Completed
This checks the proof signature, htm, htu, and iat. Which two DPoP bindings are missing, and what attack does each gap allow?
Heads-up Those prove the proof is fresh and for this request, but not that it belongs to THIS token or THIS client key. ath, the cnf.jkt thumbprint match, and jti replay-caching are the missing bindings.
Heads-up A DPoP proof is signed by the client's own key, which is carried in header.jwk and bound via cnf.jkt — not by the IdP's JWKS. The defect is the missing ath, jkt match, and jti checks.
Heads-up htu binds the proof to the target URI and must be checked (normalised as needed). Dropping it weakens the proof rather than fixing the absent ath/jkt/jti checks.
Recap
Every snippet failed the same way real OAuth code does — by skipping or inverting one mandatory check while looking correct: a missing state comparison opens login CSRF; trusting header.alg and dropping aud/nonce opens algorithm confusion, audience confusion, and replay; minting without used-marking defeats rotation; and a DPoP verifier without ath, the cnf.jkt thumbprint match, and jti replay-caching is not actually sender-constrained. Review for the complete set: read the path, name the absent check, apply the precise fix.