OAuth 2.0 PKCE — Proof Key for Code Exchange
debt(d5/e3/b3/t7)
Closest to 'specialist tool catches it' (d5). The detection_hints list semgrep as the tool, with automated=yes and code patterns for missing code_challenge, plain method, or implicit flow. Semgrep is a specialist SAST tool — not a default linter — so misuse won't be caught by a compiler or standard linter, but can be caught with a configured semgrep rule.
Closest to 'simple parameterised fix' (e3). The quick_fix is essentially a pattern replacement: generate fresh random_bytes(32) per request, compute BASE64URL(SHA256(verifier)), set method to S256. Common mistakes (plain method, weak verifier, reuse) are each one-to-a-few line corrections within the OAuth flow initiation code. This is contained within one component but requires touching the auth flow correctly, so slightly more than a one-liner (e1) but well within a single component.
Closest to 'localised tax' (b3). The PKCE implementation is scoped to the OAuth authorisation flow — the code_verifier/challenge generation and storage per request. Applies_to is web context only. Once correctly implemented it doesn't significantly shape other areas of the codebase; future maintainers only feel the weight when modifying the OAuth flow itself.
Closest to 'serious trap' (t7). The misconception field states that developers believe PKCE is only needed for mobile/public clients, when OAuth 2.1 mandates it for all clients including confidential server-side apps. Additionally, using code_challenge_method=plain (which many docs show as an option) looks equivalent to S256 but completely defeats the protection — contradicting how developers expect a 'method parameter' to provide equivalent security. These are documented gotchas that contradict intuition from reading other OAuth guides.
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