Implement OAuth authorization code flow with PKCE
The canonical, framework-agnostic walkthrough of the authorization code grant with PKCE, the flow OAuth 2.1 recommends for every client type.
- An OAuth client registered with your authorization server
- A registered redirect URI
- Any HTTP client and a SHA-256 implementation
Authorization code flow with PKCE (Proof Key for Code Exchange, RFC 7636) is the flow OAuth 2.1 recommends for every client type, browser apps, mobile apps, and confidential web servers alike. It replaces the deprecated implicit flow. This recipe shows the flow in plain HTTP so you can map it onto any language or framework. For a Next.js wiring, see add login to a Next.js app.
Why PKCE
Without PKCE, an attacker who intercepts the authorization code (through a malicious app on a shared redirect URI, a logged URL, or a referrer leak) can redeem it for tokens. PKCE binds the code to a secret the client generates per request, so a stolen code is useless without the matching verifier.
1. Generate the code verifier and challenge
The code_verifier is a high-entropy random string. The code_challenge is its base64url-encoded SHA-256 hash. Always use the S256 method, never plain.
import { createHash, randomBytes } from 'node:crypto';
const base64url = (buf) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const codeVerifier = base64url(randomBytes(32)); // 43-128 chars
const codeChallenge = base64url(
createHash('sha256').update(codeVerifier).digest(),
);
Store the code_verifier server-side or in a secure, short-lived cookie keyed to the user's session. You will need it at the token exchange step. Generate a state value the same way and store it too.
2. Redirect to the authorization endpoint
GET https://id.example.com/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile%20email
&state=RANDOM_STATE
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256
The user authenticates and consents at the authorization server. Your app never sees their credentials, which is the entire point of delegating to an identity provider.
3. Receive the authorization code
The server redirects back to your redirect_uri:
GET https://app.example.com/callback?code=AUTH_CODE&state=RANDOM_STATE
Validate that the returned state exactly matches what you stored. A mismatch means a forged or replayed callback: reject it.
4. Exchange the code for tokens
Send the code and the original code_verifier to the token endpoint. The server recomputes the challenge from the verifier and compares it to the one from step 2.
const res = await fetch('https://id.example.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: 'https://app.example.com/callback',
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET', // omit for public clients
code_verifier: codeVerifier,
}),
});
const tokens = await res.json();
// {
// access_token, token_type: "Bearer", expires_in,
// refresh_token, id_token, scope
// }
Public clients (browser-only SPAs, native apps) have no client_secret. PKCE is what makes the flow safe for them.
5. Use and refresh the tokens
- The
access_tokenis sent as aBearertoken to call protected APIs. See protect an API with OAuth scopes. - The
id_token(OpenID Connect only) tells you who logged in. Verify it before trusting it: see validate a JWT. - The
refresh_token, when issued, lets you obtain a new access token without sending the user back through login. OAuth 2.1 recommends rotating refresh tokens on each use.
// Refresh
await fetch('https://id.example.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token,
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET',
}),
});
Common mistakes
- Using
code_challenge_method=plain. Always useS256. - Skipping
statevalidation, which leaves the redirect open to CSRF. - Storing the
code_verifiersomewhere an attacker on the same device can read. Keep it bound to the session. - Putting tokens in
localStorage. Prefer server-side sessions orhttpOnlycookies (see the Next.js recipe).
The authoritative references are OAuth 2.1 and OpenID Connect.