PKCE (Proof Key for Code Exchange)

PKCE (Proof Key for Code Exchange)

A security extension to the OAuth2 Authorization Code Flow that prevents authorization code interception attacks by binding the code request to a secret known only to the initiating party.


Core Idea

PKCE (pronounced "pixie", RFC 7636) closes a critical vulnerability in the Authorization Code Flow: an attacker who intercepts the authorization code (via a malicious redirect URI, browser history, or server logs) cannot exchange it for tokens without possessing the original secret that was generated when the flow began.

The mechanism is elegant: before redirecting to the IdP, the client generates a random secret (code_verifier), hashes it (code_challenge), and sends only the hash to the IdP. The IdP stores the hash. When the code is exchanged for tokens, the client sends the original verifier — the IdP hashes it and compares. An attacker who captured only the code cannot forge this.

PKCE was originally designed for public clients (mobile apps, SPAs) that cannot hold a client_secret. It is now also required for confidential clients (server-side BFFs) by OAuth 2.1 and the OAuth Security BCP (RFC 9700).


Key Principles

  1. Code verifier and challenge are single-use, per-request — a new pair is generated for every authorization request; reuse breaks the security property
  2. Only the hash travels over the network to the IdP — the verifier never leaves the initiating client until exchange time
  3. S256 method is mandatorycode_challenge_method=S256 (SHA-256 hash) is required; plain method provides no security and must not be used
  4. PKCE does not replace client_secret for confidential clients — it is used in addition; a BFF sends both its client_secret and the code_verifier

How It Works

Step-by-Step Mechanism

1. Client generates:
   code_verifier  = random 43–128 character string (BASE64URL-safe, high entropy)
   code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))

2. Authorization Request (client → IdP):
   GET /authorize?
     response_type=code
     &client_id=bff-client
     &redirect_uri=https://bff.example.com/login/oauth2/code/idp
     &scope=openid profile email
     &state=<CSRF token>
     &code_challenge=<BASE64URL(SHA256(verifier))>
     &code_challenge_method=S256

   IdP stores: { code → code_challenge }

3. User authenticates at IdP → IdP redirects back with:
   https://bff.example.com/login/oauth2/code/idp?code=AUTH_CODE&state=<CSRF token>

4. Token Exchange (BFF → IdP, server-side):
   POST /token
     grant_type=authorization_code
     &code=AUTH_CODE
     &redirect_uri=https://bff.example.com/login/oauth2/code/idp
     &client_id=bff-client
     &client_secret=<secret>          ← confidential client includes this
     &code_verifier=<original secret>  ← IdP verifies: SHA256(verifier) == stored challenge

5. IdP responds with: access_token, refresh_token, id_token

Why Code Interception Fails

An attacker who intercepts AUTH_CODE in step 3 attempts:

POST /token
  code=AUTH_CODE
  code_verifier=???  ← attacker does not know the verifier

The IdP rejects the exchange because SHA256(attacker's guess) will not match the stored code_challenge.

Code Verifier Generation (Java 21)

import java.security.SecureRandom;
import java.security.MessageDigest;
import java.util.Base64;
 
public class PkceUtil {
 
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
 
    /** Generates a cryptographically random code_verifier (43-128 chars, RFC 7636 §4.1) */
    public static String generateCodeVerifier() {
        byte[] randomBytes = new byte[64]; // 64 bytes → 86 BASE64URL chars (within 43–128 limit)
        SECURE_RANDOM.nextBytes(randomBytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
    }
 
    /** Derives code_challenge = BASE64URL(SHA-256(ASCII(code_verifier))) */
    public static String deriveCodeChallenge(String codeVerifier) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(codeVerifier.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
            return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
        } catch (java.security.NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 must be available on any JVM", e);
        }
    }
}

Spring Security Handles PKCE Automatically

When using spring-boot-starter-oauth2-client with Spring Security 6.x, PKCE is generated and managed automatically by OAuth2AuthorizationRequestResolver. You do not implement PKCE manually — Spring Security generates the verifier, stores it in the session, and sends the verifier at exchange time.

To verify PKCE is enabled in your IdP registration, confirm the client registration type:

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: bff-client
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            # Spring Security uses Authorization Code + PKCE by default for 'confidential' clients
            # when the IdP's well-known endpoint advertises PKCE support
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: openid, profile, email, offline_access

Spring Security 6.x enables PKCE for public clients (no client_secret) automatically. For confidential clients, PKCE is also applied when the IdP supports it (indicated in the OIDC Discovery Document via code_challenge_methods_supported).


PKCE in the BFF-for-SPA Flow

In the BFF-For-SPA pattern, the BFF (not the Angular app) is the OAuth2 client that performs the PKCE dance:

  1. Angular navigates to /oauth2/authorization/keycloak on the BFF
  2. Spring Security generates code_verifier, stores in the BFF's server-side session, computes code_challenge
  3. BFF redirects browser to IdP with code_challenge
  4. IdP redirects back to BFF with authorization_code
  5. BFF retrieves code_verifier from session, sends it with the code to the token endpoint
  6. Tokens are stored in server-side session — Angular never sees them

This is the correct security posture: PKCE runs server-side in the BFF, not in browser JavaScript.


Common Misconceptions

  • "PKCE is only for mobile apps and SPAs" — OAuth 2.1 and RFC 9700 require PKCE for ALL clients, including confidential server-side clients.
  • "PKCE replaces the client_secret" — For confidential clients, both are required. PKCE defends against code interception; the client_secret defends against impersonation of the client itself.
  • "plain method is acceptable as a fallback"code_challenge_method=plain is equivalent to no PKCE at all and must be rejected by compliant IdPs.
  • "I need to implement PKCE manually in Spring" — Spring Security 6.x handles all PKCE mechanics automatically. You configure the client registration; Spring does the rest.
  • "PKCE protects against all attacks" — PKCE specifically prevents authorization code interception. It does not prevent phishing, open redirectors, or CSRF (which is addressed by the state parameter and/or PKCE itself in some IdP implementations).

Why It Matters

PKCE is now a baseline security requirement for any OAuth2 implementation. Without it, a captured authorization code can be traded for long-lived tokens. The BFF-For-SPA pattern depends on PKCE operating correctly at the BFF layer to ensure tokens never reach the browser.

RFC 9700 (OAuth 2.0 Security Best Current Practice) mandates PKCE for all grant types where it is applicable. Any IdP (Keycloak, Auth0, Okta, Microsoft Entra) worth using requires or strongly recommends it.


ConceptRelationship
OAuth2-BFF-PatternPKCE is a required component of the Authorization Code Flow used in BFF-as-OAuth2-client
BFF-For-SPAThe BFF executes the PKCE flow on behalf of the Angular SPA
Token-Relay-PatternAfter PKCE-secured token acquisition, tokens are relayed to downstream services
BFF-PatternThe BFF's server-side nature is what enables safe PKCE and token storage
  • OAuth2-OIDC-Flows — PKCE is a required extension to the Authorization Code flow; OAuth2-OIDC-Flows documents all grant types including the PKCE requirement per RFC 9700

Sources

  • RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients (tools.ietf.org/html/rfc7636)
  • RFC 9700 — OAuth 2.0 Security Best Current Practice (tools.ietf.org/html/rfc9700)
  • Spring Security Reference: OAuth2 Login — Authorization Code + PKCE
  • Philippe De Ryck, "Securing SPAs and Blazor Applications Using the BFF Pattern from IdentityModel"
  • OAuth 2.1 Draft — PKCE required for all clients