OAuth 2.0 PKCE — Proof Key for Code Exchange
Also Known As
TL;DR
Explanation
PKCE (RFC 7636, pronounced 'pixy') was designed for public clients that cannot securely store a client_secret — native mobile apps and single-page applications. Without PKCE, if an attacker intercepts the authorisation code (via a malicious app registered to the same redirect URI, or a URL leak), they can exchange it for tokens. PKCE binds the authorisation request to a secret the client controls. Flow: (1) Client generates a random `code_verifier` (43–128 chars, URL-safe). (2) Client computes `code_challenge = BASE64URL(SHA256(code_verifier))`. (3) Client sends `code_challenge` and `code_challenge_method=S256` with the authorisation request. (4) Auth server stores the challenge. (5) Client sends `code_verifier` with the token request. (6) Auth server hashes it and compares to the stored challenge — only the original client can pass. OAuth 2.1 mandates PKCE for all clients including confidential ones, making it the universal standard.
Diagram
sequenceDiagram
participant App as Client App
participant Auth as Auth Server
participant API as Resource API
App->>App: Generate code_verifier + SHA256 challenge
App->>Auth: Auth request + code_challenge + S256
Auth->>App: Redirect with auth code
App->>Auth: Token request + code + code_verifier
Auth->>Auth: SHA256(verifier) == stored challenge?
Auth->>App: Access token + refresh token
App->>API: Request with access token
Common Misconception
Why It Matters
Common Mistakes
- Using `code_challenge_method=plain` instead of `S256` — plain sends the verifier unhashed, meaning interception of the challenge is enough to complete the attack.
- Generating a weak `code_verifier` (too short or low entropy) — must be 43–128 chars of cryptographically random URL-safe characters.
- Reusing `code_verifier` across requests — each authorisation request must use a fresh, unique verifier.
- Still using the implicit flow for SPAs — the implicit flow is deprecated; use PKCE authorisation code flow instead.
Code Examples
// Plain method — challenge equals verifier, no security benefit:
$codeVerifier = 'my-static-verifier'; // weak, static, reused
$codeChallenge = $codeVerifier; // plain method — trivially reversed
$authUrl = 'https://auth.example.com/oauth/authorize'
. '?code_challenge=' . $codeChallenge
. '&code_challenge_method=plain'; // insecure
// Correct PKCE with S256:
function generatePKCE(): array {
// 32 bytes = 43 base64url chars after encoding (within 43-128 range)
$verifierBytes = random_bytes(32);
$codeVerifier = rtrim(strtr(base64_encode($verifierBytes), '+/', '-_'), '=');
$challengeBytes = hash('sha256', $codeVerifier, true);
$codeChallenge = rtrim(strtr(base64_encode($challengeBytes), '+/', '-_'), '=');
return [
'code_verifier' => $codeVerifier, // store in session
'code_challenge' => $codeChallenge, // send to auth server
];
}
$pkce = generatePKCE();
$_SESSION['code_verifier'] = $pkce['code_verifier'];
$authUrl = 'https://auth.example.com/oauth/authorize'
. '?response_type=code'
. '&code_challenge=' . $pkce['code_challenge']
. '&code_challenge_method=S256';
// At token exchange:
// POST code_verifier = $_SESSION['code_verifier'] — server hashes and compares