OAuth 2.0 Security: Common Vulnerabilities and Best Practices
Introduction
OAuth 2.0 is the industry-standard protocol for authorization, enabling applications to access resources on behalf of users. However, improper implementation can lead to serious security vulnerabilities including account takeovers, data breaches, and unauthorized access. This article explores common OAuth 2.0 security issues and demonstrates secure implementation patterns.
OAuth 2.0 Flows Overview
OAuth 2.0 defines several grant types:
- Authorization Code Grant: Most secure, recommended for server-side apps
- Authorization Code with PKCE: For mobile and single-page applications
- Client Credentials: For server-to-server communication
- Implicit Flow: Deprecated, should not be used
- Resource Owner Password Credentials: Legacy, avoid when possible
Common OAuth 2.0 Vulnerabilities
1. Missing State Parameter (CSRF Attack)
javascript// VULNERABLE: No state parameter app.get('/auth/google', (req, res) => { const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + `client_id=${CLIENT_ID}&` + `redirect_uri=${REDIRECT_URI}&` + `response_type=code&` + `scope=openid email profile`; // Missing state parameter - vulnerable to CSRF! res.redirect(authUrl); }); // SECURE: Include state parameter const crypto = require('crypto'); app.get('/auth/google', (req, res) => { // Generate cryptographically random state const state = crypto.randomBytes(32).toString('hex'); // Store state in session req.session.oauthState = state; const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + `client_id=${CLIENT_ID}&` + `redirect_uri=${REDIRECT_URI}&` + `response_type=code&` + `scope=openid email profile&` + `state=${state}`; res.redirect(authUrl); }); app.get('/auth/callback', async (req, res) => { const { code, state } = req.query; // Verify state parameter if (!state || state !== req.session.oauthState) { return res.status(403).json({ error: 'Invalid state parameter' }); } // Clear used state delete req.session.oauthState; // Exchange code for tokens // ... continue with token exchange });
2. Missing PKCE (Code Interception)
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks, especially important for mobile and single-page applications.
javascriptconst crypto = require('crypto'); // Generate code verifier and challenge for PKCE function generatePKCE() { // Generate random code verifier const codeVerifier = crypto.randomBytes(32).toString('base64url'); // Create code challenge (SHA256 hash of verifier) const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); return { codeVerifier, codeChallenge }; } // Authorization request with PKCE app.get('/auth/google', (req, res) => { const state = crypto.randomBytes(32).toString('hex'); const { codeVerifier, codeChallenge } = generatePKCE(); // Store state and code verifier in session req.session.oauthState = state; req.session.codeVerifier = codeVerifier; const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + `client_id=${CLIENT_ID}&` + `redirect_uri=${REDIRECT_URI}&` + `response_type=code&` + `scope=openid email profile&` + `state=${state}&` + `code_challenge=${codeChallenge}&` + `code_challenge_method=S256`; res.redirect(authUrl); }); // Token exchange with PKCE app.get('/auth/callback', async (req, res) => { const { code, state } = req.query; // Verify state if (!state || state !== req.session.oauthState) { return res.status(403).json({ error: 'Invalid state parameter' }); } const codeVerifier = req.session.codeVerifier; // Clear session data delete req.session.oauthState; delete req.session.codeVerifier; try { // Exchange code for tokens with code_verifier const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: code, code_verifier: codeVerifier, // Include PKCE verifier grant_type: 'authorization_code', redirect_uri: REDIRECT_URI }) }); const tokens = await tokenResponse.json(); if (!tokenResponse.ok) { throw new Error(tokens.error_description || 'Token exchange failed'); } // Validate and use tokens const user = await validateAndGetUser(tokens.id_token); req.session.userId = user.id; res.redirect('/dashboard'); } catch (error) { console.error('OAuth error:', error); res.status(500).json({ error: 'Authentication failed' }); } });
3. Insecure Redirect URI Validation
javascript// VULNERABLE: Weak redirect URI validation const ALLOWED_REDIRECTS = ['https://myapp.com/callback']; app.post('/oauth/authorize', (req, res) => { const { redirect_uri } = req.query; // Vulnerable: String contains check if (!ALLOWED_REDIRECTS.some(uri => redirect_uri.includes(uri))) { return res.status(400).json({ error: 'Invalid redirect URI' }); } // Attacker could use: https://myapp.com/callback.evil.com }); // SECURE: Exact match validation app.post('/oauth/authorize', (req, res) => { const { redirect_uri, client_id } = req.query; // Get registered redirect URIs for this client const client = getClientById(client_id); if (!client) { return res.status(400).json({ error: 'Invalid client' }); } // Exact match validation if (!client.redirectUris.includes(redirect_uri)) { return res.status(400).json({ error: 'Invalid redirect URI' }); } // Additional validation try { const redirectUrl = new URL(redirect_uri); // Ensure HTTPS (except localhost for development) if (redirectUrl.protocol !== 'https:' && redirectUrl.hostname !== 'localhost' && redirectUrl.hostname !== '127.0.0.1') { return res.status(400).json({ error: 'Redirect URI must use HTTPS' }); } // Proceed with authorization // ... } catch (error) { return res.status(400).json({ error: 'Invalid redirect URI format' }); } });
4. Token Validation Issues
javascriptconst jwt = require('jsonwebtoken'); const jwksClient = require('jwks-rsa'); // Create JWKS client to fetch public keys const client = jwksClient({ jwksUri: 'https://accounts.google.com/.well-known/jwks.json', cache: true, cacheMaxAge: 86400000 // 24 hours }); // Get signing key from JWKS function getKey(header, callback) { client.getSigningKey(header.kid, (err, key) => { if (err) { callback(err); return; } const signingKey = key.getPublicKey(); callback(null, signingKey); }); } // SECURE: Proper ID token validation async function validateIdToken(idToken) { return new Promise((resolve, reject) => { jwt.verify( idToken, getKey, { algorithms: ['RS256'], // Specify algorithm issuer: 'https://accounts.google.com', // Verify issuer audience: CLIENT_ID, // Verify audience }, (err, decoded) => { if (err) { reject(new Error('Invalid ID token')); return; } // Additional validations const now = Math.floor(Date.now() / 1000); // Check expiration if (decoded.exp < now) { reject(new Error('Token expired')); return; } // Check issued at if (decoded.iat > now + 300) { // Allow 5 min clock skew reject(new Error('Token issued in the future')); return; } // Check nonce if used if (decoded.nonce && decoded.nonce !== req.session.nonce) { reject(new Error('Invalid nonce')); return; } resolve(decoded); } ); }); }
Complete Secure OAuth 2.0 Implementation
Authorization Server (Provider)
javascriptconst express = require('express'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const app = express(); // Client registry (in production, use database) const clients = { 'client123': { id: 'client123', secret: 'client_secret_hash', // Store hashed redirectUris: ['https://app.example.com/callback'], name: 'Example App' } }; // Authorization endpoint app.get('/oauth/authorize', (req, res) => { const { client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method } = req.query; // Validate client const client = clients[client_id]; if (!client) { return res.status(400).json({ error: 'invalid_client' }); } // Validate redirect URI if (!client.redirectUris.includes(redirect_uri)) { return res.status(400).json({ error: 'invalid_redirect_uri' }); } // Validate response type if (response_type !== 'code') { return res.status(400).json({ error: 'unsupported_response_type' }); } // Validate PKCE if (!code_challenge || code_challenge_method !== 'S256') { return res.status(400).json({ error: 'invalid_request', error_description: 'PKCE required' }); } // Show consent screen (simplified) res.render('consent', { client: client.name, scope: scope, state: state }); }); // Token endpoint app.post('/oauth/token', async (req, res) => { const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier } = req.body; // Validate client credentials const client = clients[client_id]; if (!client || !await validateClientSecret(client_secret, client.secret)) { return res.status(401).json({ error: 'invalid_client' }); } if (grant_type !== 'authorization_code') { return res.status(400).json({ error: 'unsupported_grant_type' }); } // Verify authorization code (from database/cache) const authCode = await getAuthorizationCode(code); if (!authCode || authCode.clientId !== client_id) { return res.status(400).json({ error: 'invalid_grant' }); } // Check expiration (codes should be short-lived, ~10 minutes) if (Date.now() > authCode.expiresAt) { return res.status(400).json({ error: 'invalid_grant' }); } // Validate PKCE const expectedChallenge = crypto .createHash('sha256') .update(code_verifier) .digest('base64url'); if (expectedChallenge !== authCode.codeChallenge) { return res.status(400).json({ error: 'invalid_grant' }); } // Validate redirect URI if (redirect_uri !== authCode.redirectUri) { return res.status(400).json({ error: 'invalid_grant' }); } // Generate tokens const accessToken = generateAccessToken(authCode.userId, authCode.scope); const refreshToken = generateRefreshToken(authCode.userId); const idToken = generateIdToken(authCode.userId, client_id); // Invalidate authorization code (one-time use) await invalidateAuthorizationCode(code); res.json({ access_token: accessToken, token_type: 'Bearer', expires_in: 3600, refresh_token: refreshToken, id_token: idToken, scope: authCode.scope }); }); function generateAccessToken(userId, scope) { return jwt.sign( { sub: userId, scope: scope, type: 'access' }, process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'https://auth.example.com', audience: 'https://api.example.com' } ); } function generateIdToken(userId, clientId) { return jwt.sign( { sub: userId, aud: clientId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 }, process.env.JWT_SECRET, { issuer: 'https://auth.example.com' } ); }
OAuth 2.0 Security Best Practices
- Always Use State Parameter - Prevents CSRF attacks
- Implement PKCE - Essential for mobile/SPA apps, recommended for all flows
- Exact Redirect URI Matching - Never use partial or regex matching
- Short-lived Authorization Codes - Expire within 10 minutes, single-use only
- Validate ID Tokens Properly - Verify signature, issuer, audience, expiration
- Use HTTPS Everywhere - Except localhost in development
- Secure Token Storage - Use httpOnly cookies or secure storage
- Implement Token Revocation - Allow users to revoke access
- Scope Validation - Request minimal scopes, validate on resource server
- Regular Security Audits - Test for common OAuth vulnerabilities
Client-Side Storage Security
javascript// VULNERABLE: localStorage (accessible via XSS) localStorage.setItem('access_token', token); // BETTER: httpOnly cookie (set from server) res.cookie('access_token', token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 3600000 }); // BEST: Backend-for-Frontend (BFF) pattern // Store tokens server-side, use session cookies for client
Conclusion
OAuth 2.0 security requires careful implementation of multiple protection mechanisms. Always use the Authorization Code flow with PKCE, implement proper state validation, validate redirect URIs exactly, and properly verify tokens.
For comprehensive OAuth 2.0 security assessments and implementation reviews, contact the Whitespots team for expert consultation and penetration testing services.