Security GuideApril 18, 2026

Keycloak OAuth 2.0 Authorization Code Flow with PKCE: A Complete Guide

Most OAuth 2.0 tutorials skip PKCE. Here's why that's a mistake, and exactly how to implement it correctly with Keycloak.

KT

KeycloakPro Team

KeycloakPro Team

Most OAuth 2.0 tutorials skip PKCE because it adds two lines to the client setup. Those two lines are the difference between a flow that can be intercepted by a malicious app and one that cannot.

Why PKCE Exists

The authorization code interception attack is not theoretical. It targets public clients — browser-based SPAs, native mobile apps — that cannot securely store a client secret. In those environments, a malicious app installed on the same device can register a custom URI scheme identical to your app's callback URI. When the authorization server redirects with the authorization code, the OS delivers it to the malicious app instead of yours.

The malicious app then calls the token endpoint with your client_id (publicly visible in every auth request) and the intercepted authorization code. Without PKCE, the token exchange succeeds. The attacker has your user's tokens.

This is a real attack, not a hypothetical. On iOS and Android, multiple apps can claim the same custom URI scheme (e.g., myapp://callback). The OS resolves the conflict by letting the user choose which app handles the URL — or by choosing arbitrarily. On Android, the behavior differs by API level. Neither behavior is safe without additional protection. On desktop, it is worse: any process can register a URI scheme handler.

PKCE closes this attack by replacing the static client secret with a per-request cryptographic proof. Before starting the flow, the client generates a random code_verifier. It computes a SHA-256 hash of that verifier — the code_challenge — and includes the challenge in the authorization request. When exchanging the code for tokens, the client sends the original code_verifier. The authorization server hashes it independently and verifies it matches the stored challenge.

An attacker who intercepts the authorization code has no path forward. They cannot construct a valid code_verifier that produces the challenge the authorization server is expecting. The verifier is never transmitted during the authorization phase, so intercepting the redirect gives the attacker nothing usable.

PKCE is not optional for public clients. OAuth 2.1 removes all other options. Any new implementation that skips PKCE is implementing a deprecated, weaker version of the protocol.

How PKCE Works Mechanically

The math is simple: SHA-256 is a one-way function. Knowing the hash does not let you derive the input. The code_challenge is transmitted in the authorization request (visible to anyone). The code_verifier is only transmitted in the token request over TLS, and only after the authorization code has already been issued.

Here is the exact generation logic:

// pkce.ts
export function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  // Base64url encoding — no padding, URL-safe characters
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

export async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

export function generateState(): string {
  const array = new Uint8Array(16);
  crypto.getRandomValues(array);
  return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
}

Use crypto.getRandomValues — not Math.random. The verifier must be cryptographically random; a predictable verifier is as exploitable as having no PKCE at all. The Web Crypto API is available in all modern browsers and in Node.js 15+, so there is no reason to reach for a third-party library for this step.

The required length for code_verifier is between 43 and 128 characters (base64url encoded). The 32-byte random buffer above encodes to exactly 43 characters after removing base64 padding — right at the minimum. Using 48 bytes would give you 64 characters, which is a more comfortable buffer if you want to stay well above the minimum.

The code_challenge_method must be S256. The spec allows plain (sending the verifier as the challenge directly), but plain provides no security benefit over having no PKCE. It exists only for backward compatibility with extremely constrained clients that cannot compute a SHA-256 hash. If your client environment can execute JavaScript at all, it can compute SHA-256. There is no legitimate reason to use plain in a new implementation.

Configuring Keycloak for PKCE

By default, recent Keycloak versions permit but do not require PKCE for public clients. That permissive default is wrong for production. Configure the client to enforce it.

Client setup in the Admin Console:

  1. Create a new client: Clients → Create client → Client type: OpenID Connect
  2. Client ID: myapp-frontend (or your app name)
  3. Client authentication: OFF — this is a public client; there is no secret
  4. Authentication flow: Standard flow only — uncheck everything else
  5. Valid redirect URIs: your app's callback URL (exact match in production)
  6. Web origins: your app's origin (required for the CORS preflight on the token endpoint)

Enforcing PKCE:

Navigate to the client → Advanced tab → find Proof Key for Code Exchange Code Challenge Method → set it to S256.

With S256 set, Keycloak rejects any authorization request from this client that omits code_challenge and code_challenge_method. The door to the non-PKCE path is closed server-side. This is the only setting that actually enforces PKCE — without it, your frontend PKCE implementation is a safety mechanism that can be bypassed by any client that chooses to omit it.

If you manage many clients and want PKCE enforced globally, Keycloak's client policies let you apply the requirement at the realm level. Go to Realm settings → Client policies, create a policy that matches all clients of type public, and add the PKCE Enforcer executor. This prevents any public client in the realm from completing an authorization code flow without PKCE, regardless of how the individual client is configured.

You can verify the enforcement is active by attempting an authorization request without PKCE parameters:

curl -v "http://localhost:8080/realms/myapp/protocol/openid-connect/auth\
?response_type=code\
&client_id=myapp-frontend\
&redirect_uri=http://localhost:3000/callback\
&scope=openid"

Keycloak returns a 400 Bad Request with error=invalid_request and error_description=Missing parameter: code_challenge_method. That is the correct behavior.

Implementing the Full Flow in TypeScript

Building the Authorization URL

// auth.ts
const KEYCLOAK_URL = 'http://localhost:8080';
const REALM = 'myapp';
const CLIENT_ID = 'myapp-frontend';
const REDIRECT_URI = 'http://localhost:3000/callback';

export async function initiateLogin(): Promise<void> {
  const verifier = generateCodeVerifier();
  const challenge = await generateCodeChallenge(verifier);
  const state = generateState();

  // sessionStorage, not localStorage — cleared when the tab closes
  sessionStorage.setItem('pkce_verifier', verifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile email',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
  });

  window.location.href =
    `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/auth?${params}`;
}

The verifier is stored in sessionStorage rather than localStorage for a deliberate reason. localStorage persists indefinitely across browser sessions and is readable by any JavaScript running on the same origin, including injected scripts. The code_verifier is an ephemeral, single-use value — it only needs to exist for the seconds between launching the authorization request and completing the callback. sessionStorage is scoped to the current tab and cleared automatically when the tab closes, which matches exactly the lifespan the verifier needs. Persisting it longer than necessary in localStorage expands the window during which a cross-site scripting vulnerability could extract and misuse it.

The state parameter protects against cross-site request forgery on the callback endpoint. State mismatch means either a CSRF attempt or a lost session — both cases warrant rejecting the callback and restarting the login.

Handling the Callback and Exchanging Tokens

// callback.ts
export async function handleCallback(): Promise<TokenResponse> {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const returnedState = params.get('state');
  const error = params.get('error');

  if (error) {
    throw new Error(`Authorization failed: ${error} — ${params.get('error_description')}`);
  }

  if (!code) {
    throw new Error('No authorization code in callback — flow was not completed');
  }

  const storedState = sessionStorage.getItem('oauth_state');
  if (returnedState !== storedState) {
    // State mismatch means either CSRF or the session was lost — restart login
    throw new Error('State mismatch — possible CSRF; restarting login flow');
  }

  const verifier = sessionStorage.getItem('pkce_verifier');
  if (!verifier) {
    throw new Error('No code_verifier in session — cannot complete token exchange');
  }

  const response = await fetch(
    `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        code,
        code_verifier: verifier,   // The original verifier, not the challenge
      }),
    }
  );

  if (!response.ok) {
    const body = await response.json();
    throw new Error(`Token exchange failed: ${body.error_description ?? body.error}`);
  }

  // Single-use — clear immediately after successful exchange
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');

  return response.json() as Promise<TokenResponse>;
}

interface TokenResponse {
  access_token: string;
  id_token: string;
  refresh_token: string;
  expires_in: number;
  token_type: string;
}

The code_verifier in the POST body is the raw verifier, not the SHA-256 hash. Keycloak hashes it server-side and compares it to the stored challenge. Sending the challenge here instead of the verifier is a common mistake — the exchange will fail with invalid_grant.

Common Mistakes

Storing the code_verifier in localStorage. The verifier is short-lived and single-use. It should exist only long enough to complete the callback, which happens in the same browser session. localStorage persists across sessions and is readable by any JavaScript on the page. Use sessionStorage — it scopes to the tab and is cleared automatically when the tab closes.

Not enforcing S256 on the Keycloak client. Generating PKCE in your frontend code while leaving the client configuration permissive gives you the appearance of security without the reality. A malicious client can initiate the authorization code flow against the same client_id without PKCE — Keycloak will serve the request if you haven't set the Code Challenge Method to S256. Enforcement must happen at the server. Client-side PKCE generation without server-side enforcement is security theater.

Using the authorization code flow without PKCE for confidential clients. PKCE is mandatory for public clients and beneficial for confidential ones. A confidential client with a client secret that also uses PKCE gets defense in depth: even if the client secret leaks, an intercepted authorization code cannot be exchanged without the code_verifier. The cost is two extra lines of code.

Reusing the same verifier across requests. The verifier must be freshly generated for every authorization request. A static verifier turns PKCE into a second static secret — it degrades to the same security posture as a public client with an embedded secret. Generate, use once, discard.

What the Token Exchange Looks Like on the Wire

For debugging, here is the complete token exchange request as a raw POST:

curl -X POST \
  "http://localhost:8080/realms/myapp/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=myapp-frontend" \
  -d "redirect_uri=http://localhost:3000/callback" \
  -d "code=<authorization_code_from_callback>" \
  -d "code_verifier=<original_verifier_from_sessionStorage>"

A successful response returns the full token set. A 400 with invalid_grant means either the code is expired (codes are single-use and expire in 60 seconds by default), the code_verifier does not match the stored code_challenge, or the redirect_uri does not exactly match what was sent in the authorization request.

Keycloak logs the specific rejection reason at DEBUG level. If you're troubleshooting, set the realm log level to DEBUG temporarily in the Admin Console under Realm settings → Events → Log level.

Closing

PKCE is part of the OAuth 2.1 draft as a hard requirement, and Keycloak supports enforcing it at the server level. The full implementation here — verifier generation, authorization URL construction, callback handling, token exchange — is fewer than 100 lines of TypeScript. If you are building on top of an existing OAuth library, verify that it is using S256 rather than plain.


Running into issues configuring PKCE in your Keycloak setup? We offer a free 30-minute architecture review for teams implementing OAuth 2.0 and OIDC. Book a session →

Need Help With Keycloak?

Our team specializes in production-grade Keycloak deployments. Get a free 30-minute strategy consultation.

Book a Free Strategy Call