Setting the file. One moment.
Subchapter 127.4
concepts/pkce.mdMarkdown14 KBView on GitHub
PKCE (pronounced “pixy”) is a security extension to OAuth 2.0 for public clients that cannot securely store a client secret.
| Client Type | Can Store Secrets? | PKCE Required? | Examples |
|---|---|---|---|
| Confidential | ✅ Yes (server-side) | ❌ Optional | Backend servers, traditional web apps |
| Public | ❌ No (client-side) | ✅ Required | Mobile apps, SPAs, desktop apps |
###Why Public Clients Can’t Keep Secrets
// ❌ INSECURE: Client secret embedded in mobile/SPA code
const CLIENT_SECRET = "abc123"; // Anyone can decompile/inspect and find this!
// Attacker can:
// 1. Extract CLIENT_SECRET from app
// 2. Intercept authorization code
// 3. Exchange code for tokens using stolen secretPKCE prevents authorization code interception attacks without requiring a client secret.
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Mobile App │ │ Zoom OAuth │ │ Attacker │
│ (Public) │ │ Server │ │ (Intercepting│
└──────┬──────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. Generate code_verifier │ │
│ (random 43-128 chars) │ │
│ │ │
│ 2. Create code_challenge │ │
│ SHA256(code_verifier) │ │
│ │ │
│ 3. Authorize with challenge │ │
│https://zoom.us/oauth/authorize?│ │
│ code_challenge={HASH} │ │
│ code_challenge_method=S256 │ │
│──────────────────────────────>│ │
│ │ │
│ 4. User authorizes │ │
│──────────────────────────────>│ │
│ │ │
│ 5. Return authorization code │ │
│<──────────────────────────────│ │
│ │ │
const crypto = require('crypto');
function generatePKCE() {
// Generate random code_verifier (43-128 characters)
const verifier = crypto
.randomBytes(32)
.toString('base64url'); // base64url encoding (no padding)
// Create code_challenge: SHA256(code_verifier)
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return {
code_verifier: verifier,
code_challenge: challenge
};
}
// Example output:
// {
// code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
// code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
// }// Store in session (server-side) or secure storage (mobile)
req.session.pkce_verifier = code_verifier;
// For mobile apps, use secure storage:
// - iOS: Keychain
// - Android: EncryptedSharedPreferencesapp.get('/auth', (req, res) => {
const { code_verifier, code_challenge } = generatePKCE();
// Store verifier for later (Step 5)
req.session.pkce_verifier = code_verifier;
const authURL = new URL('https://zoom.us/oauth/authorize');
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('client_id', process.env.ZOOM_CLIENT_ID);
authURL.searchParams.set('redirect_uri', process.env.ZOOM_REDIRECT_URL);
authURL.searchParams.set('code_challenge', code_challenge);
authURL.searchParams.set('code_challenge_method', 'S256'); // SHA256
res.redirect(authURL.toString());
});app.get('/callback', async (req, res) => {
const { code } = req.query;
const code_verifier = req.session.pkce_verifier; // Retrieve stored verifier
try {
const response = await axios.post(
'https://zoom.us/oauth/token',
qs.stringify({
grant_type: 'authorization_code',
code: code,
redirect_uri: process.env.ZOOM_REDIRECT_URL,
code_verifier: code_verifier // Prove possession of original verifier
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const { access_token, refresh_token } = response.data;
// Success! Store tokens
await saveTokens({ access_token, refresh_token });
// Clean up verifier
delete req.session.pkce_verifier;
res.send('Authorization successful!');
} catch (error) {
res.status(500).send('Token exchange failed');
}
});Zoom supports two code_challenge_method values:
| Method | Description | Security | Support |
|---|---|---|---|
| S256 | SHA256 hash of verifier | ✅ Recommended | All OAuth 2.0 servers |
| plain | Verifier sent as-is (no hash) | ⚠️ Weaker | Legacy support only |
Always use S256:
// ✅ RECOMMENDED
authURL.searchParams.set('code_challenge_method', 'S256');// ❌ AVOID (less secure)
authURL.searchParams.set('code_challenge_method', 'plain');Attacker intercepts authorization code
↓
Attacker exchanges code with client_secret
↓
Attacker gets access_token and refresh_token
↓
Attacker has full account accessAttacker intercepts authorization code
↓
Attacker tries to exchange code
↓
Zoom: "Provide code_verifier"
↓
Attacker doesn't have original verifier
↓
Token exchange FAILS
↓
Legitimate app exchanges with correct verifier
↓
Legitimate app gets tokens// ❌ WRONG: Generating new verifier on callback
app.get('/callback', async (req, res) => {
const { code_challenge } = generatePKCE(); // New verifier!
// This won't match the original challenge
});// ✅ CORRECT: Retrieve stored verifier
app.get('/callback', async (req, res) => {
const code_verifier = req.session.pkce_verifier; // Original verifier
});// ❌ AVOID
code_challenge_method: 'plain' // Less secure// ✅ RECOMMENDED
code_challenge_method: 'S256' // SHA256 hashing// ❌ WRONG: Including verifier in URL
authURL.searchParams.set('code_verifier', verifier); // Don't send verifier during auth!// ✅ CORRECT: Only send code_challenge
authURL.searchParams.set('code_challenge', challenge);
authURL.searchParams.set('code_challenge_method', 'S256');import CryptoKit
func generatePKCE() -> (verifier: String, challenge: String) {
// Generate random verifier
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
let verifier = Data(buffer).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
// Create SHA256 challenge
let data = verifier.data(using: .utf8)!
let hash = SHA256.hash(data: data)
let challenge = Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return (verifier, challenge)
}
// Store verifier in Keychain
KeychainWrapper.standard.set(verifier, forKey: "pkce_verifier")import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
fun generatePKCE(): Pair<String, String> {
// Generate random verifier
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
val verifier = Base64.encodeToString(bytes,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
// Create SHA256 challenge
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(verifier.toByteArray())
val challenge = Base64.encodeToString(hash,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
return Pair(verifier, challenge)
}
// Store verifier in EncryptedSharedPreferences
val encryptedPrefs = EncryptedSharedPreferences.create(...)
encryptedPrefs.edit().putString("pkce_verifier", verifier).apply()const { code_verifier, code_challenge } = generatePKCE();
console.log('Verifier length:', code_verifier.length); // Should be 43-128
console.log('Challenge length:', code_challenge.length); // Should be 43 for S256
console.log('Verifier chars:', /^[A-Za-z0-9_-]+$/.test(code_verifier)); // true
console.log('Challenge chars:', /^[A-Za-z0-9_-]+$/.test(code_challenge)); // trueconst crypto = require('crypto');
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const expected_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
const computed_challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
console.log(computed_challenge === expected_challenge); // Should be truecurl -X POST https://zoom.us/oauth/token \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=YOUR_AUTH_CODE" \
-d "redirect_uri=YOUR_REDIRECT_URI" \
-d "code_verifier=YOUR_CODE_VERIFIER"
# Should return { access_token, refresh_token, ... }PKCE follows RFC 7636: