OAuth 2.0 authorization code with PKCE, and what OIDC adds
Only a one-time code crosses the browser; the verifier redeems it, and each token is accepted only by the audience it names.
Predict first
Before you read: predict
A candidate clicks "Sign in with your university account". ExamHall's web server keeps a fresh PKCE verifier and sends her browser to the university's authorization endpoint with ExamHall's client id, its callback URL, scope=openid, a state value and the PKCE challenge. She signs in at the university and approves. The university then redirects her browser to ExamHall's callback.
What does that redirect carry?
The 30-second version
OAuth lets an app call an API for a user. The browser goes to the authorization server with a PKCE challenge, hashed from a secret verifier. Only a one-time code comes back through the browser; the app swaps code plus verifier for tokens. The access token is for the API; OIDC's ID token tells the app who signed in.
Only a one-time code crosses the browser; the verifier redeems it, and each token is accepted only by the audience it names.
How it works
Step 1 of 13
Analogy: A storage site's office letting a handyman into a tenant's unit
The handyman gives the tenant a request form pressed with the wax print of his signet ring, and keeps the ring. At the office she shows her ID and signs; the office hands her a numbered one-time slip for him. At the back window he shows slip and ring, and leaves with a gate pass and a letter.
| At the storage site | In the code flow |
|---|---|
| the wax print on the form; the ring stays in his pocket | the challenge in the request; the verifier kept (steps 2–3) |
| a numbered slip anyone in the corridor could read | the one-time code on the redirect (step 5) |
| the clerk checks the ring fits the print, then cancels the slip | the token request, the PKCE check, one use (steps 6–8) |
| a gate pass for unit 14 | the access token, for the API (step 9) |
| a letter to the handyman naming the tenant | the ID token, for the client (step 9) |
| unit 14's gate reads the pass: this site, unit 14, today; never the letter | iss, aud, exp at the API (step 10) |
Breaks down when: a print shows the ring's pattern, but a SHA-256 hash cannot be run backwards; and anyone holding a copied pass gets in, as anyone holding a bearer token does.
Contrasting case: ExamHall's own session cookie. One party issues it and checks it, so there is no code, no exchange and no audience to choose (auth-sessions-vs-jwt, no pack yet).
In real terms: the browser carries a claim slip, never a credential; the server redeems it with a secret only it holds; each token is then read by its named reader alone.
Examples, run for real
4.1 ExamHall EXH-2450: a pilot university's sign-in, before and after
The pilot university's authorization server requires an S256 challenge from every public client. ExamHall's first sign-in sent none, so every candidate was turned away before reaching the university's login page. MR !2750 sends a fresh challenge with each sign-in, redeems the code with its verifier, and starts ExamHall's own session:
4.1 before (no code_challenge sent): 3 sign-ins · refused at the authorization endpoint with error=invalid_request: 3 · candidates who reached the university's sign-in: 0
4.1 after MR !2750, one sign-in traced:
4.1 1 browser -> authorization endpoint: response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method=S256
4.1 2 the candidate signs in at the university and approves (sign-ins recorded by the server: 1)
4.1 3 authorization server -> browser -> callback: code, state (state as sent: true)
4.1 4 ExamHall server -> token endpoint, directly: grant_type, code, redirect_uri, client_id, code_verifier
4.1 5 token endpoint -> ExamHall server: access_token, token_type, expires_in, id_token
4.1 6 ExamHall session started for the ID token's sub cand-0417 (ID token checked: iss, aud, exp)
The university refused the request at its own endpoint, so no candidate reached the login page (steps 2–3). After the fix, only the code and the state crossed the browser; the verifier went to the token endpoint directly (steps 5–6). ExamHall then keeps a session of its own (the contrasting case).
4.2 Payments ledger: one wallet sign-in's code, redeemed four ways
A wallet app's sign-in at its provider returns one code. Four token requests then try that code in turn:
4.2 one sign-in's code, four token requests in turn:
4.2 no code_verifier -> 400 invalid_grant
4.2 another sign-in's code_verifier -> 400 invalid_grant
4.2 its own code_verifier -> 200 access_token, id_token
4.2 its own code_verifier, a second time -> 400 invalid_grant
Without the verifier, or with a verifier from a different sign-in, the code is not redeemed (step 7); a code that worked once never works again (step 8).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A budgeting app signs a user in at her bank's authorization server with OpenID Connect and asks to read her accounts from the bank's accounts API. The token response holds an id_token and an access_token, both signed with the bank's key and unexpired. The accounts API checks each bearer token with:
The app's developer sends each of the two tokens to the accounts API in turn.
Which does the API accept?
await jwtVerify(token, bankKeys, { issuer: 'https://auth.bank.test', audience: 'https://accounts.bank.test' });Question 2 of 5
A concert-ticket app starts each sign-in with a fresh code_verifier. This time it is:
Q7r2mX9kLp0vT4sWb8eYz1nH6cJgA3uRfK5dVxNqIoE
The app sends code_challenge_method=S256.
Which code_challenge goes in its authorization request?
Question 3 of 5
A shop's mobile app signs customers in at an authorization server that implements PKCE exactly as RFC 7636 specifies and accepts both plain and S256 challenges. The app's sign-in code:
s256() returns the base64url SHA-256 of the verifier, as RFC 7636 defines it for S256. The customer signs in and approves.
What happens next?
const verifier = newVerifier();const authorize = { response_type: 'code', client_id: 'shop-ios', redirect_uri: 'com.shop.app:/cb', state, code_challenge: s256(verifier) };// … the server redirects back with ?code=…&state=…const exchange = { grant_type: 'authorization_code', code, redirect_uri: 'com.shop.app:/cb', client_id: 'shop-ios', code_verifier: verifier };Question 4 of 5
A payments platform runs one identity provider for all its merchants. Each merchant is a tenant whose tokens carry its own issuer, https://id.pay.test/<tenant>, and all tenants sign with the provider's one key set. Merchant acme's ledger API must serve acme's users only. It checks bearer tokens with:
A user of another merchant, globex, signs in at globex's tenant, gets an access token for the ledger API, and the API serves her acme's ledger.
Which is the bug?
const keys = createRemoteJWKSet(new URL('https://id.pay.test/keys'));const { payload } = await jwtVerify(token, keys, { audience: 'https://ledger.pay.test' });Question 5 of 5
ExamHall's callback receives an id_token from the university's token endpoint and must start a session only for a candidate who has just signed in to ExamHall. ExamHall's client id at the university is examhall-web, UNI holds the university's issuer, https://idp.uni.test, and the university's ID tokens expire ten minutes after issue. A test hands the callback four tokens, all signed with the university's key: the candidate's fresh ID token for ExamHall; an ID token the university issued to its library app; the candidate's ID token for ExamHall from yesterday; and an access token for the university's course API, https://courses.uni.test. The callback checks each one with:
Which tokens does it refuse?
await jwtVerify(t, uniKeys, { issuer: UNI, audience: 'examhall-web' });Go deeper
Pro tips
- Name the algorithms your verifier accepts. Pass
algorithmstojwtVerifywith exactly what your issuer signs with, rather than whatever a token's header says. When: any API or client that verifies tokens (step 12). Verify: jose's docs say the option "Defaults to all algorithms applicable to the key or secret. Unsecured JWTs (alg: "none") are never accepted."; RFC 8725 §3.1 says libraries "MUST NOT use any other algorithms" than the set the caller specifies. Status: DOC (jose v6.2.12JWTVerifyOptions, RFC 8725, fetched 2026-10-02).
Source
No item is keyed on a source; every key is executed on the fixture. Library behaviour can change between versions: the jose facts are as of 2026-10-02, for jose 6.2.12. Valid as of 2026-10-02.
- The curriculum row's source. Quoted: "The redirection URI includes an authorization code and any local state provided by the client earlier"; "If an authorization code is used more than once, the authorization server MUST deny the request". Supports steps 1–2, 5–6 and 8.
- IETF, RFC 7636, Proof Key for Code Exchange (PKCE): https://www.rfc-editor.org/rfc/rfc7636.txt (
rfc7636.txt). The row's source. Quoted: "code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))";code_challenge_method"defaults to "plain" if not present in the request"; Appendix B's verifierdBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkand challengeE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM. Supports steps 3–4 and 7. - IETF, RFC 9700, Best Current Practice for OAuth 2.0 Security (January 2025): https://www.rfc-editor.org/rfc/rfc9700.txt (
rfc9700.txt). The row's source. Quoted: "Public clients MUST use PKCE"; "this advice applies to all kinds of OAuth clients, including web applications"; "PKCE ensures that an attacker cannot redeem a stolen authorization code at the token endpoint of the authorization server without knowledge of the code_verifier"; access tokens "SHOULD be audience-restricted". Supports steps 2, 8 and 10. RFC 9700 holds no algorithm rule; step 12 rests on OIDC Core and RFC 8725. - OpenID Foundation, OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html (
oidc-core.txt). The row's source. Quoted: the ID token'saud"MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value"; "The Client MUST validate that the aud (audience) Claim contains its client_id value"; "ID Tokens MUST NOT use none as the alg value unless the Response Type used returns no ID Token from the Authorization Endpoint". It also lets a code-flow client rely on TLS in place of the ID token's signature, so step 11 listsiss,audandexp. Supports steps 9, 11 and 12. - Quoted: "The algorithm can be changed to "none" by an attacker"; an "RS256" value "can be changed into "HS256"" so that some libraries use "the RSA public key as the HMAC shared secret". Supports step 12 and the tip; cited only, nothing built (CL-031).
- Quoted: the
issvalue "MUST exactly match" the issuer, the token "MUST be rejected if "aud" does not contain a resource indicator of the current resource server", and "The current time MUST be before the time represented by the "exp" claim." It also has the API requiretypat+jwt, which the fixture does not use: its two token kinds differ byaudalone. Supports step 10. - jose v6.2.12,
JWTVerifyOptions: https://raw.githubusercontent.com/panva/jose/v6.2.12/docs/jwt/verify/interfaces/JWTVerifyOptions.md (jose-verify-options.txt). Quoted, as of 2026-10-02:issuer"Expected JWT "iss" (Issuer) Claim value(s). Requires the claim to be present.";audiencelikewise for "aud". Thatexpis checked with no option named is observed in this lesson's runs. Supports step 10 and the tip.
Related lessons
- Related: Browser credential storage boundaries
- Related: Cookies: attributes, SameSite and partitioning
- Related: Same-origin policy and CORS
- Related: Authorization models and enforcement
- Related: Storing credentials, slow password hashing, MFA and passkeys
- Related: CSRF and SameSite: why a cookie is not consent
Checked against the tools and docs as of 2026-10-02.