PKCE
PKCE (Proof Key for Code Exchange)
PKCE (pronounced "pixy") is a security extension for OAuth 2.0 that protects the authorization code from interception. It is required for public clients (SPAs, mobile apps) and recommended for all clients.
Why PKCE?
Public clients (browser apps, mobile apps) cannot securely store a client_secret. Without PKCE, an attacker who intercepts the authorization code could exchange it for tokens. PKCE prevents this by binding the code to a secret known only to the original requester.
How It Works
1. Client generates random code_verifier (43-128 chars)
2. Client computes code_challenge = BASE64URL(SHA256(code_verifier))
3. Client sends code_challenge in the authorize request
4. SeloraX stores the challenge with the auth code
5. Client sends code_verifier in the token exchange
6. SeloraX verifies: SHA256(code_verifier) === stored challenge
Step 1: Generate Code Verifier and Challenge
// Generate a cryptographically random code verifier
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// Compute the S256 challenge
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');The code_verifier must be between 43 and 128 characters. Store it securely (e.g. in sessionStorage) for use during token exchange.
Step 2: Include Challenge in Authorize Request
GET /api/oauth/authorize?response_type=code
&client_id=sx_oc_...
&redirect_uri=https://example.com/callback
&scope=openid+profile+email
&state=random_string
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
| Parameter | Required | Description |
|---|---|---|
code_challenge | Yes | BASE64URL-encoded SHA256 hash of the code_verifier |
code_challenge_method | Yes | S256 (recommended) or plain |
:::tip
Always use S256. The plain method sends the verifier as-is and provides no protection against interception.
:::
Step 3: Exchange Code with Verifier
When exchanging the authorization code for tokens, include the original code_verifier:
POST /api/oauth/token
{
"grant_type": "authorization_code",
"client_id": "sx_oc_...",
"code": "sx_ic_...",
"redirect_uri": "https://example.com/callback",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}Note: No client_secret is needed for public clients. The code_verifier proves the requester is the same entity that initiated the flow.
Full SPA Example
// --- Step 1: Start login ---
async function startLogin() {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
const state = crypto.randomUUID();
// Store for later
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'sx_oc_...',
redirect_uri: window.location.origin + '/auth/callback',
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `https://api.selorax.io/api/oauth/authorize?${params}`;
}
// --- Step 2: Handle callback ---
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (error) {
console.error('Authorization denied:', error);
return;
}
// Verify state
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch — possible CSRF attack');
}
const codeVerifier = sessionStorage.getItem('code_verifier');
// Exchange code for tokens
const res = await fetch('https://api.selorax.io/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: 'sx_oc_...',
code,
redirect_uri: window.location.origin + '/auth/callback',
code_verifier: codeVerifier,
}),
});
const tokens = await res.json();
// Fetch user profile
const userRes = await fetch('https://api.selorax.io/api/oauth/userinfo', {
headers: { 'Authorization': `Bearer ${tokens.access_token}` },
});
const user = await userRes.json();
console.log('Logged in as:', user.name, user.email);
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth_state');
}:::warning Token storage in SPAs
Store tokens in memory (JavaScript variables) rather than localStorage or sessionStorage to reduce XSS risk. Use short-lived access tokens and refresh via the token endpoint when needed.
:::
Challenge Methods
| Method | Description | Security |
|---|---|---|
S256 | BASE64URL(SHA256(code_verifier)) | Recommended — verifier cannot be derived from challenge |
plain | code_challenge === code_verifier | Weak — only use for debugging/testing |
Error Handling
If PKCE verification fails during token exchange:
{
"message": "Invalid PKCE code_verifier.",
"error": "invalid_grant",
"status": 400
}Common causes:
code_verifierdoesn't match thecode_challengesent during authorizationcode_verifierwas not provided butcode_challengewas stored with the auth code- Using
S256method but computing the hash incorrectly (must be SHA256, then base64url-encoded without padding)