ZetPinDevelopers

PKCE

Proof Key for Code Exchange binds an authorization code to the client that requested it. It is mandatory for public clients and recommended for everyone.

Why PKCE exists

In the authorization-code flow, the code travels through the user's browser and could be intercepted — for example by another app registered on the same custom URL scheme. PKCE closes that gap: your app generates a random secret (the code_verifier) up front, sends only a hash of it (the code_challenge) on authorize, and reveals the original verifier when exchanging the code. ZetPin rehashes the verifier and rejects the exchange unless it matches the challenge, so a stolen code alone is useless.

Mandatory for public clients

Any app that cannot keep a client_secret confidential — mobile apps, single-page apps, desktop apps — must use PKCE with code_challenge_method=S256. Plain challenges are not accepted.

1. Create the verifier and challenge

The code_verifier is a high-entropy random string (43–128 characters, URL-safe). The code_challenge is the base64url-encoded SHA-256 hash of the verifier, with no padding.

JavaScript (Web Crypto)

javascript
function base64UrlEncode(bytes) {
  return btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

// 1. Random verifier (store it for the token exchange)
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const codeVerifier = base64UrlEncode(randomBytes);

// 2. Derive the S256 challenge
const digest = await crypto.subtle.digest(
  "SHA-256",
  new TextEncoder().encode(codeVerifier),
);
const codeChallenge = base64UrlEncode(digest);

console.log({ codeVerifier, codeChallenge });

Bash (openssl)

bash
# 1. Random verifier
code_verifier=$(openssl rand -base64 32 | tr '/+' '_-' | tr -d '=')

# 2. S256 challenge = base64url( sha256( verifier ) )
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -binary -sha256 \
  | openssl base64 | tr '/+' '_-' | tr -d '=')

echo "verifier:  $code_verifier"
echo "challenge: $code_challenge"

Keep the verifier

Store the code_verifier in the user's session (or secure app storage) before you redirect. You'll need the exact same value at the token endpoint — you only send the challenge on authorize.

2. Send the challenge on authorize

Add code_challenge and code_challenge_method=S256 to the authorize request.

http
GET https://api.zetpin.com/oauth/authorize
  ?client_id=zp_app_xxx
  &redirect_uri=https://yourapp.com/callback
  &response_type=code
  &scope=profile.read posts.write
  &state=8f3c9a2e
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

3. Send the verifier on token

When exchanging the returned code, include the original code_verifier. Public clients send no client_secret.

bash
curl -X POST https://api.zetpin.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=zpac_a1b2c3d4... \
  -d redirect_uri=https://yourapp.com/callback \
  -d client_id=zp_app_xxx \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

If the verifier doesn't hash to the challenge sent on authorize, the exchange fails with invalid_grant. See Errors for the full list.