OAuth 2.0 Security Code Review Guide
Table of Contents
Introduction
OAuth 2.0 is the de facto standard for authorization on the web, powering "Login with Google/Facebook/GitHub" features across millions of applications. However, its complexity and flexibility make it a common source of security vulnerabilities. A single misconfiguration can lead to complete account takeover.
OAuth security issues consistently appear in bug bounty programs, with payouts ranging from hundreds to tens of thousands of dollars. Understanding OAuth vulnerabilities is essential for any security-focused code reviewer.
OAuth Complexity = Security Risk
OAuth 2.0 has multiple grant types, flows, and extension specifications. Each combination has different security properties. Most vulnerabilities arise from developers not fully understanding the security implications of their implementation choices.
What is the primary purpose of OAuth 2.0?
OAuth 2.0 Fundamentals
Before diving into vulnerabilities, understanding the OAuth flow is essential. The Authorization Code flow with PKCE is the recommended approach for most applications.
Authorization Code Flow with PKCE
Client generates code_verifier + code_challenge (PKCE)/authorize?client_id=X&redirect_uri=Y&state=Z&code_challenge=CAuthorization server validates user credentials/callback?code=AUTH_CODE&state=Z (state must match!)POST /token with code + code_verifier + client_secretSecurity Note: PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. The code_verifier proves the token request comes from the same client that initiated the flow.
OAuth 2.0 Grant Types
| Grant Type | Use Case | Security Level |
|---|---|---|
| Authorization Code + PKCE | Web apps, SPAs, mobile apps | Highest |
| Authorization Code | Server-side web apps only | High (with client_secret) |
| Client Credentials | Machine-to-machine | High (no user context) |
| Refresh Token | Long-lived access | Medium (requires secure storage) |
| Implicit (deprecated) | Legacy SPAs | LOW - Do not use! |
| Password Grant (deprecated) | Legacy/trusted apps | LOW - Do not use! |
Authorization Request Parameters
1GET /authorize?
2 response_type=code # Request authorization code
3 &client_id=CLIENT_ID # Your application's ID
4 &redirect_uri=https://app.com/callback # Where to send the code
5 &scope=openid profile email # Requested permissions
6 &state=RANDOM_STATE_VALUE # CSRF protection (REQUIRED!)
7 &code_challenge=CHALLENGE # PKCE challenge (REQUIRED for public clients)
8 &code_challenge_method=S256 # PKCE method
9
10# Security-critical parameters:
11# - state: Prevents CSRF attacks
12# - redirect_uri: Must be strictly validated
13# - code_challenge: Prevents code interception
14# - scope: Should be minimal (least privilege)Why is the Implicit flow now considered insecure?
CSRF & State Parameter
The state parameter is the primary defense against Cross-Site Request Forgery (CSRF) attacks in OAuth. Without it, an attacker can trick a victim into completing an OAuth flow with an authorization code controlled by the attacker.
Common OAuth Attack Vectors
Attacker tricks victim into completing OAuth flow with attacker's authorization code, linking victim's account to attacker.
Manipulating redirect_uri to leak authorization codes or tokens to attacker-controlled servers.
Intercepting authorization code before it reaches legitimate client, then exchanging it for tokens.
Tokens exposed via Referrer headers, browser history, logs, or URL fragments in implicit flow.
CSRF Attack Scenario
1# ATTACK: OAuth CSRF (Login CSRF / Account Linking Attack)
2
31. Attacker starts OAuth flow on legitimate site
4 GET /authorize?client_id=APP&redirect_uri=https://app.com/callback
5
62. Attacker authenticates with THEIR account
7 Authorization server issues code for ATTACKER's account
8
93. Attacker gets callback URL but DOESN'T complete it:
10 https://app.com/callback?code=ATTACKERS_CODE
11
124. Attacker tricks victim into visiting this URL
13 (via email, chat, or CSRF in another page)
14
155. Victim's browser completes OAuth flow with ATTACKER's code
16
176. RESULT: Victim's local account is now linked to ATTACKER's
18 social account, or attacker gains access to victim's session
19
20# Without state parameter, the app cannot distinguish this attack
21# from a legitimate OAuth callback!Vulnerable Implementation (Missing State)
1// VULNERABLE: No state parameter!
2app.get('/login/google', (req, res) => {
3 const authUrl = `https://accounts.google.com/o/oauth2/auth?
4 client_id=${CLIENT_ID}
5 &redirect_uri=${REDIRECT_URI}
6 &response_type=code
7 &scope=openid email profile`;
8 // Missing: &state=RANDOM_VALUE
9 res.redirect(authUrl);
10});
11
12// VULNERABLE: No state validation!
13app.get('/callback', async (req, res) => {
14 const { code } = req.query;
15 // Missing: Validate state matches session
16
17 const tokens = await exchangeCodeForTokens(code);
18 // Attacker's code could be used here!
19
20 await linkAccountToUser(req.session.userId, tokens);
21});Secure State Implementation
1import crypto from 'crypto';
2
3// Generate cryptographically secure state
4function generateState() {
5 return crypto.randomBytes(32).toString('hex');
6}
7
8app.get('/login/google', (req, res) => {
9 // Generate and store state in session
10 const state = generateState();
11 req.session.oauthState = state;
12
13 const authUrl = new URL('https://accounts.google.com/o/oauth2/auth');
14 authUrl.searchParams.set('client_id', CLIENT_ID);
15 authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
16 authUrl.searchParams.set('response_type', 'code');
17 authUrl.searchParams.set('scope', 'openid email profile');
18 authUrl.searchParams.set('state', state); // Include state!
19
20 res.redirect(authUrl.toString());
21});
22
23app.get('/callback', async (req, res) => {
24 const { code, state } = req.query;
25
26 // CRITICAL: Validate state before using code!
27 if (!state || state !== req.session.oauthState) {
28 // Log the attempt for security monitoring
29 console.warn('OAuth state mismatch - possible CSRF attack');
30 return res.status(403).json({ error: 'Invalid state parameter' });
31 }
32
33 // Clear state to prevent replay
34 delete req.session.oauthState;
35
36 // Now safe to exchange code
37 const tokens = await exchangeCodeForTokens(code);
38 // ... rest of authentication flow
39});What should happen if the state parameter doesn't match?
Redirect URI Attacks
Redirect URI manipulation is one of the most impactful OAuth vulnerabilities. If an attacker can control where the authorization code or token is sent, they can steal credentials and take over accounts.
Redirect URI Attack Patterns
1# ATTACK 1: Open Redirect via path traversal
2Registered: https://app.com/callback
3Attack: https://app.com/callback/../../../attacker.com
4 https://app.com/callback/..%2F..%2Fattacker.com
5
6# ATTACK 2: Subdomain takeover
7Registered: https://*.app.com/callback
8Attack: https://attacker.app.com/callback
9(If attacker can claim unclaimed subdomain)
10
11# ATTACK 3: Fragment theft (Implicit flow)
12Registered: https://app.com/callback
13Attack: https://app.com/callback#access_token=STOLEN
14(Token in fragment is exposed to JavaScript on page)
15
16# ATTACK 4: Parameter pollution
17Registered: https://app.com/callback
18Attack: https://app.com/callback?redirect=https://evil.com
19(If app has secondary redirect after callback)
20
21# ATTACK 5: Localhost bypass (mobile apps)
22Registered: http://localhost:8080/callback
23Attack: Malicious app registers same localhost port firstVulnerable Redirect Validation
1// VULNERABLE: Substring matching
2function validateRedirectUri(uri, registeredUri) {
3 // Attacker: https://app.com.evil.com/callback
4 return uri.includes(registeredUri); // Returns true!
5}
6
7// VULNERABLE: Regex without anchors
8function validateRedirectUri(uri, pattern) {
9 // Pattern: /app\.com/
10 // Attacker: https://evil.com/app.com/steal
11 return pattern.test(uri); // Returns true!
12}
13
14// VULNERABLE: Protocol downgrade
15function validateRedirectUri(uri, registeredUri) {
16 const parsed = new URL(uri);
17 const registered = new URL(registeredUri);
18 // Only checks host, not protocol
19 return parsed.host === registered.host;
20 // Attacker: http://app.com/callback (MITM possible)
21}
22
23// VULNERABLE: Ignoring path
24function validateRedirectUri(uri, registeredUri) {
25 const parsed = new URL(uri);
26 const registered = new URL(registeredUri);
27 return parsed.origin === registered.origin;
28 // Attacker: https://app.com/open-redirect?url=evil.com
29}Secure Redirect Validation
1// SECURE: Exact string match (strictest)
2function validateRedirectUri(uri, allowedUris) {
3 return allowedUris.includes(uri);
4}
5
6// Usage at OAuth provider
7const ALLOWED_REDIRECT_URIS = [
8 'https://app.com/callback',
9 'https://app.com/auth/callback',
10 // No wildcards, no patterns
11];
12
13function handleAuthorize(req, res) {
14 const { redirect_uri, client_id } = req.query;
15
16 // Get registered URIs for this client
17 const client = await getClient(client_id);
18
19 // STRICT: Exact match only
20 if (!client.redirect_uris.includes(redirect_uri)) {
21 return res.status(400).json({
22 error: 'invalid_redirect_uri',
23 error_description: 'Redirect URI not registered'
24 });
25 }
26
27 // Also validate:
28 // - Must be HTTPS (except localhost for dev)
29 // - No fragments allowed
30 // - No open redirect parameters
31
32 const parsed = new URL(redirect_uri);
33 if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost') {
34 return res.status(400).json({ error: 'HTTPS required' });
35 }
36
37 if (parsed.hash) {
38 return res.status(400).json({ error: 'Fragments not allowed' });
39 }
40
41 // Proceed with authorization...
42}Never Use Wildcards in Redirect URIs
Wildcards like https://*.app.com or regex patterns significantly increase attack surface. Always use exact string matching for redirect URIs. If you need multiple URIs, register each one explicitly.
What is the safest way to validate redirect_uri?
PKCE Implementation
PKCE (Proof Key for Code Exchange, pronounced "pixy") prevents authorization code interception attacks. It binds the authorization request to the token request, ensuring only the original client can exchange the code.
PKCE Flow Explained
1# PKCE adds two parameters to OAuth flow:
2
31. CODE_VERIFIER: A random string (43-128 chars)
4 - Generated client-side
5 - Stored securely until token exchange
6 - Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
7
82. CODE_CHALLENGE: Derived from verifier
9 - Method: S256 (recommended) or plain
10 - S256: BASE64URL(SHA256(code_verifier))
11 - Sent in authorization request
12
13# Why this works:
14- Attacker intercepts authorization code
15- Attacker cannot exchange code without code_verifier
16- code_verifier never sent over network until token exchange
17- Token endpoint validates: SHA256(verifier) == challengePKCE Implementation
1import crypto from 'crypto';
2
3// Generate code verifier (43-128 characters)
4function generateCodeVerifier() {
5 return crypto.randomBytes(32)
6 .toString('base64url'); // URL-safe base64
7}
8
9// Generate code challenge from verifier
10function generateCodeChallenge(verifier) {
11 return crypto.createHash('sha256')
12 .update(verifier)
13 .digest('base64url'); // URL-safe base64
14}
15
16// Authorization request with PKCE
17app.get('/login', (req, res) => {
18 // Generate PKCE values
19 const codeVerifier = generateCodeVerifier();
20 const codeChallenge = generateCodeChallenge(codeVerifier);
21 const state = crypto.randomBytes(16).toString('hex');
22
23 // Store verifier and state securely
24 req.session.pkceVerifier = codeVerifier;
25 req.session.oauthState = state;
26
27 const authUrl = new URL('https://auth.example.com/authorize');
28 authUrl.searchParams.set('client_id', CLIENT_ID);
29 authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
30 authUrl.searchParams.set('response_type', 'code');
31 authUrl.searchParams.set('scope', 'openid profile');
32 authUrl.searchParams.set('state', state);
33 authUrl.searchParams.set('code_challenge', codeChallenge);
34 authUrl.searchParams.set('code_challenge_method', 'S256');
35
36 res.redirect(authUrl.toString());
37});
38
39// Token exchange with PKCE
40app.get('/callback', async (req, res) => {
41 const { code, state } = req.query;
42
43 // Validate state
44 if (state !== req.session.oauthState) {
45 return res.status(403).json({ error: 'Invalid state' });
46 }
47
48 // Exchange code with verifier
49 const tokenResponse = await fetch('https://auth.example.com/token', {
50 method: 'POST',
51 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
52 body: new URLSearchParams({
53 grant_type: 'authorization_code',
54 code: code,
55 redirect_uri: REDIRECT_URI,
56 client_id: CLIENT_ID,
57 code_verifier: req.session.pkceVerifier, // Prove we started the flow
58 }),
59 });
60
61 // Clear session values
62 delete req.session.pkceVerifier;
63 delete req.session.oauthState;
64
65 const tokens = await tokenResponse.json();
66 // ... handle tokens
67});PKCE is Now Required
OAuth 2.1 (draft) makes PKCE required for all clients, including confidential clients with client secrets. Even if you have a client_secret, implement PKCE for defense in depth.
Why must the code_verifier be generated client-side and never sent until token exchange?
Token Security
How you store, transmit, and handle tokens is critical. Token leakage through logs, referrer headers, or insecure storage can lead to account compromise.
Token Leakage Vectors
1// VULNERABLE: Token in URL (leaks via Referrer header)
2window.location = '/dashboard?access_token=' + token;
3// If user clicks external link, token sent in Referer header!
4
5// VULNERABLE: Token in localStorage (XSS can steal it)
6localStorage.setItem('access_token', token);
7// Any XSS vulnerability can read this!
8
9// VULNERABLE: Logging tokens
10console.log('User authenticated:', { user, token });
11// Tokens in logs = major security incident
12
13// VULNERABLE: Token in error messages
14catch (err) {
15 res.status(500).json({
16 error: err.message,
17 context: { token: req.headers.authorization } // Leaked!
18 });
19}
20
21// VULNERABLE: Long-lived tokens
22const token = jwt.sign({ userId }, SECRET, { expiresIn: '1y' });
23// 1 year token = 1 year for attacker if leakedSecure Token Handling
1// SECURE: Store tokens in httpOnly cookies
2res.cookie('access_token', accessToken, {
3 httpOnly: true, // JavaScript cannot access
4 secure: true, // HTTPS only
5 sameSite: 'lax', // CSRF protection
6 maxAge: 15 * 60 * 1000, // 15 minutes
7 path: '/',
8});
9
10// SECURE: Separate access and refresh token storage
11res.cookie('access_token', accessToken, {
12 httpOnly: true,
13 secure: true,
14 sameSite: 'strict',
15 maxAge: 15 * 60 * 1000, // Short-lived
16});
17
18res.cookie('refresh_token', refreshToken, {
19 httpOnly: true,
20 secure: true,
21 sameSite: 'strict',
22 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
23 path: '/api/auth/refresh', // Only sent to refresh endpoint
24});
25
26// SECURE: Token rotation on use
27async function refreshTokens(refreshToken) {
28 // Validate refresh token
29 const session = await validateRefreshToken(refreshToken);
30
31 // Rotate refresh token (one-time use)
32 await revokeRefreshToken(refreshToken);
33 const newRefreshToken = await generateRefreshToken(session.userId);
34
35 // Generate new access token
36 const newAccessToken = generateAccessToken(session.userId);
37
38 return { accessToken: newAccessToken, refreshToken: newRefreshToken };
39}Token Storage Options
| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
| localStorage | HIGH - JS can read | None | Avoid for tokens |
| sessionStorage | HIGH - JS can read | None | Avoid for tokens |
| Cookie (default) | LOW | HIGH | Not recommended |
| Cookie (httpOnly) | NONE | MEDIUM | Good with SameSite |
| Cookie (httpOnly + SameSite) | NONE | LOW | Recommended |
| Memory only | LOW | None | Good for SPAs (lost on refresh) |
Why should access tokens have short expiration times?
Prevention Techniques
Securing OAuth requires attention to detail across multiple components. Here is a comprehensive checklist for OAuth security.
OAuth Security Checklist
| Control | Implementation | Priority |
|---|---|---|
| State parameter | Cryptographically random, validated on callback | Critical |
| PKCE | S256 challenge, verifier stored securely | Critical |
| Redirect URI validation | Exact string match, no wildcards | Critical |
| HTTPS only | All OAuth endpoints and redirects | Critical |
| Token storage | httpOnly cookies or secure memory | Critical |
| Short token lifetime | Access: 15-60 min, Refresh: 7-30 days | High |
| Token rotation | Rotate refresh tokens on use | High |
| Scope minimization | Request only necessary permissions | High |
| Client secret protection | Never expose in client-side code | Critical |
| Nonce for OIDC | Prevent token replay attacks | High |
Complete Secure OAuth Implementation
1import crypto from 'crypto';
2import { OAuth2Client } from 'google-auth-library';
3
4const oauthClient = new OAuth2Client(
5 process.env.GOOGLE_CLIENT_ID,
6 process.env.GOOGLE_CLIENT_SECRET,
7 process.env.GOOGLE_REDIRECT_URI
8);
9
10// Secure login initiation
11app.get('/auth/google', (req, res) => {
12 // Generate security parameters
13 const state = crypto.randomBytes(32).toString('hex');
14 const nonce = crypto.randomBytes(32).toString('hex');
15 const codeVerifier = crypto.randomBytes(32).toString('base64url');
16 const codeChallenge = crypto.createHash('sha256')
17 .update(codeVerifier)
18 .digest('base64url');
19
20 // Store in session (server-side)
21 req.session.oauth = {
22 state,
23 nonce,
24 codeVerifier,
25 initiatedAt: Date.now(),
26 };
27
28 const authUrl = oauthClient.generateAuthUrl({
29 access_type: 'offline',
30 scope: ['openid', 'email', 'profile'],
31 state,
32 nonce,
33 code_challenge: codeChallenge,
34 code_challenge_method: 'S256',
35 prompt: 'consent', // Force consent to get refresh token
36 });
37
38 res.redirect(authUrl);
39});
40
41// Secure callback handling
42app.get('/auth/google/callback', async (req, res) => {
43 try {
44 const { code, state, error } = req.query;
45 const oauthSession = req.session.oauth;
46
47 // Check for OAuth errors
48 if (error) {
49 console.error('OAuth error:', error);
50 return res.redirect('/login?error=oauth_denied');
51 }
52
53 // Validate session exists
54 if (!oauthSession) {
55 console.warn('No OAuth session found');
56 return res.redirect('/login?error=session_expired');
57 }
58
59 // Validate state (CSRF protection)
60 if (!state || state !== oauthSession.state) {
61 console.warn('State mismatch - possible CSRF');
62 return res.redirect('/login?error=invalid_state');
63 }
64
65 // Validate timing (prevent old auth attempts)
66 const maxAge = 10 * 60 * 1000; // 10 minutes
67 if (Date.now() - oauthSession.initiatedAt > maxAge) {
68 return res.redirect('/login?error=expired');
69 }
70
71 // Exchange code for tokens with PKCE verifier
72 const { tokens } = await oauthClient.getToken({
73 code,
74 codeVerifier: oauthSession.codeVerifier,
75 });
76
77 // Validate ID token
78 const ticket = await oauthClient.verifyIdToken({
79 idToken: tokens.id_token,
80 audience: process.env.GOOGLE_CLIENT_ID,
81 });
82
83 const payload = ticket.getPayload();
84
85 // Validate nonce (replay protection)
86 if (payload.nonce !== oauthSession.nonce) {
87 console.warn('Nonce mismatch - possible replay attack');
88 return res.redirect('/login?error=invalid_nonce');
89 }
90
91 // Clear OAuth session
92 delete req.session.oauth;
93
94 // Create application session
95 const user = await findOrCreateUser(payload);
96 req.session.userId = user.id;
97
98 // Store tokens securely if needed
99 if (tokens.refresh_token) {
100 await storeRefreshToken(user.id, tokens.refresh_token);
101 }
102
103 res.redirect('/dashboard');
104
105 } catch (error) {
106 console.error('OAuth callback error:', error);
107 res.redirect('/login?error=auth_failed');
108 }
109});What is the purpose of the nonce parameter in OpenID Connect?