Skip to content

PKCE explained

Proof Key for Code Exchange (RFC 7636) is an OAuth 2.0 extension that prevents authorization codes from being intercepted and exchanged by an attacker. PKCE is required for every public client (no client_secret) on Little X Little. It's recommended for confidential clients too.

Why

Without PKCE, the authorization code returned to your redirect_uri is a bearer secret. If an attacker intercepts it (malicious browser extension, OS-level URL handler, log file leak), they can exchange it for tokens.

With PKCE, the attacker also needs the original code_verifier, which never leaves your client.

How

sequenceDiagram
  participant C as Client
  participant L as id.littlexlittle.org
  C->>C: verifier = random 43-128 chars
  C->>C: challenge = base64url(SHA256(verifier))
  C->>L: /authorize?code_challenge=...&code_challenge_method=S256
  L-->>C: 302 ?code=...
  C->>L: /token (code + verifier)
  L->>L: assert SHA256(verifier) == stored challenge
  L-->>C: id_token + access_token + refresh_token

Generating the values

js function base64url(bytes) { return btoa(String.fromCharCode(...bytes)) .replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); } const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); const challenge = base64url(new Uint8Array(digest));

php $verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');

python import base64, hashlib, secrets verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode() challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode()

Storing the verifier

The verifier is short-lived (until the callback fires) but must survive across the redirect. Use whichever fits:

Client type Where to store
Server-side web app Server session ($_SESSION['code_verifier']).
SPA sessionStorage (cleared on tab close).
Mobile In-memory, scoped to the auth controller.

code_challenge_method

Only S256 is accepted. The legacy plain method is rejected with 400 invalid_request.

Common mistakes

  • Using the same verifier for multiple flows. Always generate a fresh one per /authorize request.
  • Logging the verifier. It's a secret — treat it like a password.
  • Storing the verifier in localStorage. That persists across tabs and is reachable from XSS. Use sessionStorage.
  • Forgetting the verifier in the token request. The exchange will return 400 invalid_grant.