# PKCE (Proof Key for Code Exchange) PKCE (pronounced "pixy") is a security extension to OAuth 2.0 for public clients that cannot securely store a client secret. ## When PKCE is Required | 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 ```javascript // ❌ 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 secret ``` ## How PKCE Works PKCE prevents authorization code interception attacks **without requiring a client secret**. ### Flow Diagram ``` ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ 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 │ │ │<──────────────────────────────│ │ │ │ │ │ │ Attacker intercepts code │ │ │<───────────────────────────────│ │ │ │ │ │ Attacker tries to exchange │ │ │ (but doesn't have verifier!) │ │ │<───────────────────────────────│ │ │ │ │ │ REJECTED: Missing verifier │ │ │───────────────────────────────>│ │ │ │ │ 6. Exchange code with verifier│ │ │ POST /oauth/token │ │ │ code={CODE} │ │ │ code_verifier={ORIGINAL} │ │ │──────────────────────────────>│ │ │ │ │ │ │ Verify: │ │ │ SHA256(code_verifier) │ │ │ == code_challenge? │ │ │ │ │ 7. Return tokens │ │ │<──────────────────────────────│ │ │ │ │ ``` ### Key Concept - **code_verifier:** Random secret generated by app (kept secret) - **code_challenge:** SHA256 hash of code_verifier (sent to Zoom) - **Zoom stores challenge:** During authorization - **App proves possession:** By providing original verifier during token exchange - **Attacker fails:** Even with intercepted code, can't generate matching verifier --- ## Implementation ### Step 1: Generate PKCE Parameters ```javascript 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" // } ``` ### Step 2: Store code_verifier Securely ```javascript // Store in session (server-side) or secure storage (mobile) req.session.pkce_verifier = code_verifier; // For mobile apps, use secure storage: // - iOS: Keychain // - Android: EncryptedSharedPreferences ``` ### Step 3: Redirect to Authorization with code_challenge ```javascript app.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()); }); ``` ### Step 4: Exchange Code with code_verifier ```javascript 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'); } }); ``` --- ## PKCE Methods 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:** ```javascript // ✅ RECOMMENDED authURL.searchParams.set('code_challenge_method', 'S256'); ``` ```javascript // ❌ AVOID (less secure) authURL.searchParams.set('code_challenge_method', 'plain'); ``` --- ## Security Benefits ### Without PKCE (Vulnerable) ``` Attacker intercepts authorization code ↓ Attacker exchanges code with client_secret ↓ Attacker gets access_token and refresh_token ↓ Attacker has full account access ``` ### With PKCE (Protected) ``` Attacker 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 ``` --- ## Common Mistakes ### 1. Not Storing code_verifier ```javascript // ❌ 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 }); ``` ```javascript // ✅ CORRECT: Retrieve stored verifier app.get('/callback', async (req, res) => { const code_verifier = req.session.pkce_verifier; // Original verifier }); ``` ### 2. Using 'plain' Method ```javascript // ❌ AVOID code_challenge_method: 'plain' // Less secure ``` ```javascript // ✅ RECOMMENDED code_challenge_method: 'S256' // SHA256 hashing ``` ### 3. Exposing code_verifier ```javascript // ❌ WRONG: Including verifier in URL authURL.searchParams.set('code_verifier', verifier); // Don't send verifier during auth! ``` ```javascript // ✅ CORRECT: Only send code_challenge authURL.searchParams.set('code_challenge', challenge); authURL.searchParams.set('code_challenge_method', 'S256'); ``` --- ## Mobile App Implementation ### iOS (Swift) ```swift 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") ``` ### Android (Kotlin) ```kotlin import java.security.MessageDigest import java.security.SecureRandom import android.util.Base64 fun generatePKCE(): Pair { // 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() ``` --- ## Testing PKCE Implementation ### 1. Verify code_challenge Format ```javascript 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)); // true ``` ### 2. Verify SHA256 Hashing ```javascript const 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 true ``` ### 3. Test Token Exchange ```bash curl -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 Specification PKCE follows **RFC 7636**: - https://datatracker.ietf.org/doc/html/rfc7636 --- ## Next Steps - **Implement PKCE in your app** → [../examples/pkce-implementation.md](../examples/pkce-implementation.md) - **Add state parameter** → [state-parameter.md](state-parameter.md) - **Understand OAuth flows** → [oauth-flows.md](oauth-flows.md)