---
title: PKCE explained
---

# PKCE explained

**P**roof **K**ey for **C**ode **E**xchange ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)) 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

```mermaid
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

=== "JavaScript (browser)"

    ```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"

    ```php
    $verifier  = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
    $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
    ```

=== "Python"

    ```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`.
