Skip to content

Proof Key for Code Exchange (PKCE)

PKCE (pronounced "pixy") is a security extension to OAuth 2.0 that protects the authorization code exchange from interception attacks. Originally designed for mobile and single-page apps that cannot store a client secret, PKCE is now a best practice for all OAuth clients.

SingleSign requires PKCE for public clients and recommends it for confidential (server) clients.


The Problem PKCE Solves

In the standard Authorization Code flow, an attacker who intercepts the authorization code could exchange it for tokens. This is especially risky for:

  • Mobile apps — Custom URI schemes can be registered by multiple apps. A malicious app could register the same scheme and intercept the redirect.
  • SPAs — JavaScript in the browser is inherently exposed. An XSS vulnerability could leak the authorization code.
  • Network attacks — An attacker on the same network could observe the redirect URL in certain scenarios.

Without PKCE, whoever has the authorization code can exchange it for tokens. With PKCE, only the party that started the flow can complete it.


How PKCE Works

PKCE adds a cryptographic proof that links the authorization request to the token exchange. Here's the process:

1. Generate a Code Verifier

The code verifier is a cryptographically random string between 43 and 128 characters, using unreserved characters (A-Z, a-z, 0-9, -, ., _, ~).

function generateCodeVerifier(): string {
  const array = new Uint8Array(32); // 32 bytes → 43 base64url chars
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

Why 32 bytes? 32 bytes of randomness provides 256 bits of entropy, which is more than enough to prevent brute-force attacks. The base64url encoding expands this to 43 characters, meeting the minimum length requirement.

2. Compute the Code Challenge

The code challenge is a SHA-256 hash of the verifier, base64url-encoded. This is the S256 method.

async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return base64UrlEncode(new Uint8Array(digest));
}

The relationship: code_challenge = BASE64URL(SHA256(code_verifier))

3. Send the Challenge with the Authorization Request

Include the code challenge and method in the authorization URL:

GET https://api.singlesign.com/oauth/authorize
  ?platform_guid=YOUR_PLATFORM_GUID
  &response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://app.example.com/callback
  &scope=openid+profile+email
  &state=RANDOM_STATE
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

SingleSign stores the challenge and associates it with the authorization session.

4. Send the Verifier with the Token Request

When exchanging the authorization code, include the original code verifier:

POST https://api.singlesign.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://app.example.com/callback
&client_id=YOUR_CLIENT_ID
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&platform_guid=YOUR_PLATFORM_GUID

5. Server Verification

SingleSign computes BASE64URL(SHA256(code_verifier)) and compares it to the stored code_challenge. If they match, the tokens are issued. If not, the request is rejected with invalid_grant.


The S256 Method

SingleSign supports the S256 (SHA-256) challenge method. The plain method (where the challenge equals the verifier) is not supported because it provides no security benefit — an attacker who intercepts the challenge could replay it.

S256: code_challenge = BASE64URL(SHA256(code_verifier))

Base64url Encoding

Standard base64 uses +, /, and = characters that are not safe in URLs. Base64url replaces them:

Standard Base64Base64url
+-
/_
= (padding)removed
function base64UrlEncode(bytes: Uint8Array): string {
  let binary = "";
  bytes.forEach((b) => (binary += String.fromCharCode(b)));
  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

When to Use PKCE

Client TypePKCEReason
SPA (public)RequiredNo client secret; PKCE is the only protection.
Mobile / Native (public)RequiredCustom URI schemes are susceptible to interception.
Server web app (confidential)RecommendedDefense in depth alongside the client secret.
Machine-to-machineN/AUses Client Credentials grant, not Authorization Code.

Security Properties

  • One-time use — Each code verifier + challenge pair is valid for a single authorization flow.
  • Cryptographic binding — The SHA-256 hash makes it computationally infeasible to derive the verifier from the challenge.
  • Replay protection — Even if an attacker captures both the authorization code and the challenge, they cannot compute the verifier.
  • No shared secrets — Unlike client secrets, PKCE works without any pre-shared key material.

Common Mistakes

1. Reusing the Same Verifier

Problem: Generating a single verifier and using it for all login attempts.

Fix: Generate a fresh code_verifier for every authorization request.

2. Storing the Verifier in the Wrong Place

Problem: Storing the verifier in localStorage (persists across tabs and sessions) or a cookie (sent with requests).

Fix: Use sessionStorage (browser) or an in-memory variable. For mobile apps, use the platform keychain/keystore.

3. Using the plain Method

Problem: Sending code_challenge_method=plain (or omitting it, which some servers interpret as plain).

Fix: Always use S256. SingleSign does not support plain.

4. Mismatched Verifier and Challenge

Problem: The verifier sent with the token request doesn't correspond to the challenge sent with the authorization request (e.g., regenerated instead of stored).

Fix: Store the verifier before redirecting and retrieve the exact same value on callback.


PKCE in Different Languages

Python

import hashlib
import base64
import os

code_verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode("ascii")
code_challenge = (
    base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
    .rstrip(b"=")
    .decode("ascii")
)

Java

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;

byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
String codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);

byte[] digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.getBytes("US-ASCII"));
String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest);

Go

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
)

verifierBytes := make([]byte, 32)
rand.Read(verifierBytes)
codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes)

challengeHash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(challengeHash[:])

Next Steps

Last updated: 2026-02-16

Related docs