Start with Identity
Recipe · Intermediate · 25 min

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.

OAuthOIDCSecurity
Before you start
  • 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_token is sent as a Bearer token 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 use S256.
  • Skipping state validation, which leaves the redirect open to CSRF.
  • Storing the code_verifier somewhere an attacker on the same device can read. Keep it bound to the session.
  • Putting tokens in localStorage. Prefer server-side sessions or httpOnly cookies (see the Next.js recipe).

The authoritative references are OAuth 2.1 and OpenID Connect.

Free to read and use. Code is illustrative, vendor-neutral, and built on the open standards. Review it against your provider's docs and your threat model before shipping. See more in the recipes index and the standards library.