·2 min read·← All posts
Go OAuth PKCE Security

What PKCE does

The OAuth 2.0 authorization-code flow has a known weakness: if an attacker intercepts the redirect with the authorization code, they can exchange it for a token. PKCE (Proof Key for Code Exchange) fixes this.

PKCE adds two new params:

  1. code_verifier — 43-128 random URL-safe bytes the client generates.
  2. code_challengeBASE64URL(SHA256(code_verifier)) sent on the authorize request.

At the token-exchange step, the client sends the original code_verifier. The server recomputes SHA256 and compares. An intercepted code is useless without the verifier.

Genie’s implementation

// pkg/auth/oauth2/oauth2.go
func GenerateVerifier() (verifier, challenge string, err error) {
    v, err := randomToken(48)  // 64 base64url chars
    if err != nil { return "", "", err }
    h := sha256.Sum256([]byte(v))
    return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
}

Three lines plus the verify on the server side. The whole PKCE story.

What SPAs get wrong

Storing the verifier wrong. The verifier is sensitive — anyone with it can complete the flow. SPAs sometimes drop it in localStorage where every script can read it. Use sessionStorage or in-memory only.

Reusing the verifier. A new verifier per authorize request. Reusing it across requests defeats the protection.

Skipping the verify on the server. The server has to actually recompute and compare. A server that accepts the code without the verifier is back to plain OAuth code-interception.

Using plain instead of S256. The spec allows plain (verifier == challenge) for legacy systems. Don’t. Always S256.

Why OAuth 2.1 makes PKCE mandatory

OAuth 2.1 retires the implicit flow entirely and makes PKCE mandatory for all public clients. The reason is operational — too many implicit-flow apps shipped without PKCE; the spec change makes the safe choice the only choice.

For a new SPA in 2026, the rule is: OAuth 2.1 + PKCE, S256, verifier in memory, no client_secret in the SPA itself (it’s a public client).

Wire-up

// On the SPA side (TypeScript)
const verifier = generateRandomString(48);
const challenge = base64url(sha256(verifier));
sessionStorage.setItem('pkce_verifier', verifier);

// Redirect to /authorize with code_challenge
window.location = `/oauth/authorize?` + new URLSearchParams({
    client_id, redirect_uri, response_type: 'code',
    code_challenge: challenge, code_challenge_method: 'S256', state,
});

// At redirect_uri callback
const code = new URLSearchParams(location.search).get('code');
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('pkce_verifier');

const resp = await fetch('/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
        grant_type: 'authorization_code', code, code_verifier: verifier,
        client_id, redirect_uri,
    }),
});

Two-stage flow; verifier never leaves the client; server enforces. The Genie OAuth server (pkg/auth/oauth2) exposes both /authorize and /token endpoints implementing the standard exactly.

← Back to all posts