# Implement OAuth authorization code flow with PKCE

Source: https://startwithidentity.com/recipes/oidc-authorization-code-pkce/
Last updated: 2026-06-19
License: content by Start with Identity. Cite the source URL.

---

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](https://startwithidentity.com/recipes/add-login-to-nextjs/).

## 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`.

```js
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.

```js
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](https://startwithidentity.com/recipes/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](https://startwithidentity.com/recipes/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.

```js
// 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](https://startwithidentity.com/recipes/add-login-to-nextjs/)).

The authoritative references are [OAuth 2.1](https://startwithidentity.com/standards/oauth-2-1/) and [OpenID Connect](https://startwithidentity.com/standards/openid-connect/).
