OAuth 2.0 & OpenID Connect Security: Implementation Best Practices
A comprehensive guide to implementing secure OAuth 2.0 and OpenID Connect flows, avoiding common vulnerabilities, and protecting your authentication and authorization infrastructure.
API Security Team
Identity & Access Management
Introduction
OAuth 2.0 and OpenID Connect (OIDC) are foundational protocols for modern API security and identity management. While they provide robust frameworks for authorization and authentication, improper implementation can lead to serious security vulnerabilities. This guide covers essential security considerations and best practices for implementing these protocols correctly.
1. Understanding OAuth 2.0 and OpenID Connect
OAuth 2.0 Overview
OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service. It works by delegating user authentication to the service hosting the user account and authorizing third-party applications to access that account.
OpenID Connect (OIDC)
OpenID Connect is an authentication layer built on top of OAuth 2.0. It allows clients to verify the identity of end-users and obtain basic profile information through standard flows.
Key Difference:
OAuth 2.0 is for authorization (what you can do), while OpenID Connect adds authentication (who you are) on top of OAuth 2.0.
2. Choosing the Right OAuth 2.0 Flow
Authorization Code Flow with PKCE (Recommended)
The most secure flow for both web and mobile applications. PKCE (Proof Key for Code Exchange) protects against authorization code interception attacks:
// Client implementation with PKCE
import crypto from 'crypto';
// Generate code verifier and challenge
function generatePKCE() {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// Step 1: Initiate authorization
const { codeVerifier, codeChallenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex');
// Store state and verifier in session
session.set('oauth_state', state);
session.set('code_verifier', codeVerifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', process.env.CLIENT_ID);
authUrl.searchParams.set('redirect_uri', process.env.REDIRECT_URI);
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Redirect user to authorization server
res.redirect(authUrl.toString());
// Step 2: Handle callback
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Validate state parameter
if (state !== session.get('oauth_state')) {
return res.status(400).send('Invalid state parameter');
}
// Exchange code for tokens
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.REDIRECT_URI,
client_id: process.env.CLIENT_ID,
code_verifier: session.get('code_verifier')
})
});
const tokens = await tokenResponse.json();
// Store tokens securely and clean up session
// ...
});Client Credentials Flow (Server-to-Server)
For machine-to-machine communication where there's no user context:
// Server-to-server authentication
async function getAccessToken() {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + Buffer.from(
`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
).toString('base64')
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api:read api:write'
})
});
const { access_token, expires_in } = await response.json();
// Cache token with expiration
cache.set('api_token', access_token, expires_in - 60);
return access_token;
}Avoid Implicit Flow
⚠️ Security Warning:
The Implicit Flow is deprecated and should not be used. It exposes tokens in the URL fragment and browser history. Always use Authorization Code Flow with PKCE instead.
3. Token Security Best Practices
Secure Token Storage
Store tokens securely based on the application type:
// Web Application: Use httpOnly, secure cookies
app.post('/login/callback', async (req, res) => {
const tokens = await exchangeCodeForTokens(req.body.code);
// Store refresh token in httpOnly cookie
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/refresh'
});
// Access token in memory or short-lived cookie
res.cookie('access_token', tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 minutes
});
res.redirect('/dashboard');
});
// Mobile/SPA: Use secure storage
// React Native example
import * as SecureStore from 'expo-secure-store';
async function saveTokens(tokens) {
await SecureStore.setItemAsync('access_token', tokens.access_token);
await SecureStore.setItemAsync('refresh_token', tokens.refresh_token);
}
async function getAccessToken() {
return await SecureStore.getItemAsync('access_token');
}Implement Token Rotation
Rotate refresh tokens to limit the impact of token theft:
// Server-side refresh token rotation
app.post('/api/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
// Verify refresh token
const tokens = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.CLIENT_ID
})
}).then(r => r.json());
// Issue new access and refresh tokens
res.cookie('access_token', tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000
});
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/refresh'
});
res.json({ success: true });
} catch (error) {
// Clear invalid tokens
res.clearCookie('access_token');
res.clearCookie('refresh_token');
res.status(401).json({ error: 'Invalid refresh token' });
}
});Token Validation
Always validate tokens properly on the resource server:
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
// Configure JWKS client for public key retrieval
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 86400000 // 24 hours
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key?.publicKey || key?.rsaPublicKey;
callback(err, signingKey);
});
}
// Middleware to validate JWT
function validateToken(req, res, next) {
const token = req.cookies.access_token ||
req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
jwt.verify(token, getKey, {
audience: process.env.API_AUDIENCE,
issuer: 'https://auth.example.com',
algorithms: ['RS256']
}, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
// Verify scopes
const requiredScopes = req.route.meta?.scopes || [];
const tokenScopes = decoded.scope?.split(' ') || [];
const hasRequiredScopes = requiredScopes.every(
scope => tokenScopes.includes(scope)
);
if (!hasRequiredScopes) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
req.user = decoded;
next();
});
}4. Preventing Common Vulnerabilities
CSRF Protection with State Parameter
Always use and validate the state parameter to prevent CSRF attacks:
// Generate cryptographically secure state
const state = crypto.randomBytes(32).toString('hex');
session.set('oauth_state', state);
// Include in authorization request
const authUrl = `https://auth.example.com/authorize?${new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
state,
scope: 'openid profile email'
})}`;
// Validate state in callback
app.get('/callback', (req, res) => {
const { state, code } = req.query;
const sessionState = session.get('oauth_state');
if (!state || state !== sessionState) {
return res.status(400).send('Invalid state - CSRF detected');
}
session.delete('oauth_state'); // Use once
// Continue with token exchange...
});Open Redirect Prevention
Validate redirect URIs strictly to prevent open redirect attacks:
// Authorization server: Strict redirect URI validation
const ALLOWED_REDIRECT_URIS = [
'https://app.example.com/callback',
'https://app.example.com/auth/callback',
'myapp://callback' // Mobile deep link
];
function validateRedirectUri(redirectUri) {
// Exact match only - no wildcards
return ALLOWED_REDIRECT_URIS.includes(redirectUri);
}
app.post('/authorize', (req, res) => {
const { redirect_uri, client_id } = req.body;
// Verify redirect URI is registered for this client
const client = getClientById(client_id);
if (!client.redirectUris.includes(redirect_uri)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid redirect_uri'
});
}
// Continue authorization...
});Token Binding
Implement sender-constrained tokens to prevent token theft:
// DPoP (Demonstrating Proof-of-Possession) implementation
import { SignJWT } from 'jose';
async function createDPoPProof(httpMethod, httpUri, privateKey) {
const dpopProof = await new SignJWT({
htm: httpMethod,
htu: httpUri,
jti: crypto.randomUUID(),
iat: Math.floor(Date.now() / 1000)
})
.setProtectedHeader({
alg: 'ES256',
typ: 'dpop+jwt',
jwk: publicJWK
})
.sign(privateKey);
return dpopProof;
}
// Request access token with DPoP
const dpopProof = await createDPoPProof('POST', tokenEndpoint, privateKey);
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'DPoP': dpopProof
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: redirectUri
})
});5. OpenID Connect Best Practices
Validate ID Tokens Properly
async function validateIdToken(idToken, nonce) {
try {
const decoded = jwt.verify(idToken, getKey, {
issuer: 'https://auth.example.com',
audience: process.env.CLIENT_ID,
algorithms: ['RS256']
});
// Validate nonce to prevent replay attacks
if (decoded.nonce !== nonce) {
throw new Error('Invalid nonce');
}
// Validate token expiration
const now = Math.floor(Date.now() / 1000);
if (decoded.exp < now) {
throw new Error('Token expired');
}
// Check issued at time (not too old)
if (decoded.iat > now + 60) {
throw new Error('Token issued in future');
}
// Validate required claims
if (!decoded.sub) {
throw new Error('Missing sub claim');
}
return decoded;
} catch (error) {
throw new Error(`ID token validation failed: ${error.message}`);
}
}Use Nonce Parameter
// Generate and store nonce
const nonce = crypto.randomBytes(16).toString('hex');
session.set('oidc_nonce', nonce);
// Include nonce in authentication request
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('nonce', nonce);
// Validate nonce in ID token
const idToken = await validateIdToken(tokens.id_token, session.get('oidc_nonce'));
session.delete('oidc_nonce');6. Scope and Permission Management
Implement Least Privilege
// Define granular scopes
const SCOPES = {
'users:read': 'Read user information',
'users:write': 'Create and update users',
'posts:read': 'Read posts',
'posts:write': 'Create and update posts',
'posts:delete': 'Delete posts',
'admin:all': 'Full administrative access'
};
// Request only required scopes
const requiredScopes = ['openid', 'profile', 'email', 'posts:read'];
// Validate scopes in API endpoints
function requireScopes(...scopes) {
return (req, res, next) => {
const tokenScopes = req.user?.scope?.split(' ') || [];
const hasScopes = scopes.every(scope => tokenScopes.includes(scope));
if (!hasScopes) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scopes: ${scopes.join(', ')}`
});
}
next();
};
}
// Protect endpoints with scope requirements
app.get('/api/posts', validateToken, requireScopes('posts:read'), getPosts);
app.post('/api/posts', validateToken, requireScopes('posts:write'), createPost);
app.delete('/api/posts/:id', validateToken, requireScopes('posts:delete'), deletePost);7. Authorization Server Security
Rate Limiting
import rateLimit from 'express-rate-limit';
// Token endpoint rate limiting
const tokenLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
standardHeaders: true,
legacyHeaders: false,
message: 'Too many token requests, please try again later'
});
app.post('/oauth/token', tokenLimiter, handleTokenRequest);
// Authorization endpoint rate limiting
const authLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
message: 'Too many authorization requests'
});
app.get('/oauth/authorize', authLimiter, handleAuthorizeRequest);Client Authentication
// Support multiple client authentication methods
function authenticateClient(req) {
const authHeader = req.headers.authorization;
// Client Secret Basic
if (authHeader?.startsWith('Basic ')) {
const credentials = Buffer.from(
authHeader.slice(6), 'base64'
).toString('utf-8');
const [clientId, clientSecret] = credentials.split(':');
return verifyClientCredentials(clientId, clientSecret);
}
// Client Secret Post
if (req.body.client_id && req.body.client_secret) {
return verifyClientCredentials(
req.body.client_id,
req.body.client_secret
);
}
// Private Key JWT (recommended)
if (req.body.client_assertion_type ===
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer') {
return verifyClientAssertion(req.body.client_assertion);
}
throw new Error('Invalid client authentication');
}Security Checklist
OAuth 2.0 / OIDC Security Checklist:
- ✅ Use Authorization Code Flow with PKCE
- ✅ Never use Implicit Flow
- ✅ Validate state parameter for CSRF protection
- ✅ Validate nonce parameter in ID tokens
- ✅ Use strict redirect URI validation (exact match)
- ✅ Store tokens securely (httpOnly cookies, secure storage)
- ✅ Implement token rotation
- ✅ Validate JWT signatures and claims
- ✅ Use HTTPS everywhere
- ✅ Implement rate limiting on auth endpoints
- ✅ Request minimal scopes (least privilege)
- ✅ Set appropriate token expiration times
- ✅ Implement token revocation
- ✅ Use DPoP or certificate-bound tokens
- ✅ Monitor for suspicious authentication patterns
Conclusion
OAuth 2.0 and OpenID Connect provide powerful frameworks for secure authentication and authorization, but they require careful implementation. By following these best practices—using PKCE, validating all parameters, implementing proper token storage, and protecting against common vulnerabilities—you can build a robust and secure identity infrastructure.
Remember that security is not a one-time implementation but an ongoing process. Regularly review your OAuth implementation, stay updated with the latest security recommendations, and monitor for suspicious activity to maintain a strong security posture.