OAuth 2.0 Security Best Practices for API Integrations
OAuth 2.0 is the industry standard for delegated authorization, but its flexibility introduces real security risks. This guide covers PKCE, CSRF protection, token storage, and the vulnerabilities to avoid.
OAuth 2.0 is everywhere. It powers "Sign in with Google," third-party API integrations, and the authorization layer of virtually every modern developer platform. But OAuth's flexibility — the same spec covers mobile apps, SPAs, server-side apps, and machine-to-machine flows — also means there's a lot of rope to hang yourself with.
Misconfigurations in OAuth implementations have led to some high-profile account takeovers and data breaches. Understanding where the risks live and how to mitigate them is essential for any team integrating OAuth into their stack.
The Authorization Code Flow: The Only Flow You Should Use
OAuth 2.0 defines several "grant types" (flows). Many older tutorials still cover the implicit flow, which was designed for SPAs that couldn't keep a client secret. In the implicit flow, the access token is returned directly in the URL fragment after authorization — visible in browser history, referrer headers, and server logs.
The implicit flow is deprecated. RFC 9700 (OAuth 2.0 Security Best Current Practices) explicitly recommends against it. Use the authorization code flow instead, combined with PKCE for public clients.
The authorization code flow:
- Client redirects user to authorization server with a
code_challenge(PKCE). - User authenticates and grants permission.
- Authorization server redirects back with a short-lived authorization code.
- Client exchanges the code for tokens using a back-channel request (server-to-server).
- Tokens are never exposed in the URL.
PKCE: Mandatory for Public Clients
PKCE (Proof Key for Code Exchange, RFC 7636) was originally designed to secure mobile apps, which can't safely store a client secret. It's now recommended for all authorization code flows, including traditional server-side apps.
PKCE prevents authorization code interception attacks, where an attacker intercepts the authorization code (e.g., via a malicious redirect URI or a compromised redirect endpoint) and exchanges it for tokens before the legitimate client can.
How PKCE works:
// Step 1: Generate a cryptographically random code verifier
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// Step 2: Hash it to create the code challenge
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Step 3: Include code_challenge in the authorization request
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', generateState()); // See CSRF section below
// Step 4: When exchanging the code, include the code_verifier
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier, // The original random value
}),
});
The authorization server hashes the code_verifier and compares it to the stored code_challenge. An attacker who intercepts the code doesn't have the verifier, so the exchange fails.
The State Parameter: CSRF Protection for OAuth
Cross-Site Request Forgery attacks against OAuth flows are well-documented. Without a state parameter, an attacker can:
- Start an OAuth flow with their own account.
- Capture the authorization code.
- Trick a victim into completing the flow with the attacker's code.
- The victim's account gets linked to the attacker's identity.
The state parameter prevents this by binding the authorization response to the original request.
// Generate and store state before redirect
function initiateOAuthFlow(req, res) {
const state = crypto.randomBytes(16).toString('hex');
// Store in server-side session (not just a cookie the client controls)
req.session.oauthState = state;
req.session.oauthStateCreatedAt = Date.now();
const authUrl = buildAuthorizationUrl({ state, ...otherParams });
res.redirect(authUrl);
}
// Validate state on callback
function handleOAuthCallback(req, res) {
const { code, state } = req.query;
// Verify state matches what we stored
if (!state || state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter — possible CSRF attack');
}
// Clear the state to prevent replay
delete req.session.oauthState;
// Proceed with token exchange
exchangeCodeForTokens(code);
}
State should be:
- Cryptographically random (not sequential or predictable)
- Stored server-side, not just in the client
- Single-use — invalidated after the callback
- Short-lived (expire after ~10 minutes)
Redirect URI Validation
The redirect_uri is where the authorization code gets sent after the user grants permission. Lax validation has caused some of the most serious OAuth vulnerabilities.
Common mistakes:
- Prefix matching: Allowing any URI that starts with
https://example.com— an attacker registershttps://example.com.evil.com. - Subdomain wildcards: Allowing
*.example.com— an attacker takes over a subdomain. - Path traversal: Allowing
https://example.com/callbackbut not rejectinghttps://example.com/callback/../admin. - Open redirectors: The callback page has an open redirect that forwards the code to an attacker-controlled URL.
The fix:
// VULNERABLE: prefix matching
function isValidRedirectUri(uri) {
return uri.startsWith('https://myapp.com'); // Too loose
}
// SECURE: exact match against a whitelist
const ALLOWED_REDIRECT_URIS = new Set([
'https://myapp.com/auth/callback',
'https://myapp.com/auth/callback/google',
'https://staging.myapp.com/auth/callback',
]);
function isValidRedirectUri(uri) {
try {
const parsed = new URL(uri);
// Normalize to catch path traversal
const normalized = `${parsed.origin}${parsed.pathname}`;
return ALLOWED_REDIRECT_URIS.has(normalized);
} catch {
return false;
}
}
On the authorization server side: always perform exact string matching for redirect URIs, never wildcard or prefix matching.
Token Storage Best Practices
Where you store OAuth tokens depends on your application type:
Server-side web applications:
- Store tokens in server-side sessions or encrypted in the database.
- Never send tokens to the frontend unless needed.
- If the frontend needs to make API calls, proxy them through your backend.
Single-Page Applications (SPAs):
- Use authorization code flow with PKCE.
- Store access tokens in memory (JavaScript variables), not localStorage.
- Use HttpOnly cookies for refresh tokens (set by your backend).
- Accept that in-memory storage means tokens are lost on page reload — refresh tokens in secure cookies address this.
Mobile applications:
- Use the platform's secure storage (iOS Keychain, Android Keystore).
- Never store tokens in shared preferences or plain text files.
// SPA token storage pattern
class TokenManager {
private accessToken: string | null = null; // In memory only
setAccessToken(token: string) {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
clearTokens() {
this.accessToken = null;
// Refresh token is in HttpOnly cookie, cleared by backend
}
}
// Refresh token flow: SPA calls backend, backend reads HttpOnly cookie
async function refreshAccessToken(): Promise<string> {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Sends HttpOnly cookie
});
if (!response.ok) throw new Error('Refresh failed');
const { accessToken } = await response.json();
tokenManager.setAccessToken(accessToken);
return accessToken;
}
Scope Minimization
OAuth scopes define what permissions are requested. The principle of least privilege applies: request only the scopes you actually need.
Problems with over-scoping:
- Increases damage if tokens are compromised.
- Reduces user trust (who wants to grant full account access for a calendar integration?).
- Creates compliance issues when scopes include access to regulated data.
// OVER-SCOPED: requesting everything
const scopes = ['read', 'write', 'admin', 'delete', 'manage_users'];
// APPROPRIATELY SCOPED: only what's needed for this integration
const scopes = ['repos:read', 'user:email']; // GitHub example for reading repos
When integrating with third-party OAuth providers, periodically audit what scopes your application has requested and whether they're still needed. Remove scopes that are no longer used.
Client Secret Protection
Confidential clients (server-side applications) use a client secret to authenticate with the token endpoint. This secret is equivalent to a password for your application.
- Store client secrets in environment variables or a secrets manager, never in source code.
- Rotate client secrets periodically, or immediately if a breach is suspected.
- Use different client IDs and secrets for development, staging, and production.
- Never log requests that include the client secret.
# .env
GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-... # Never commit this
# In code
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!clientSecret) {
throw new Error('GOOGLE_CLIENT_SECRET environment variable not set');
}
Token Expiry and Refresh
Access tokens should be short-lived. If they're compromised, you want the window of exploitation to be small.
// Check token expiry before use
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString()
);
// Add 30-second buffer to account for clock skew
return payload.exp < (Date.now() / 1000) + 30;
} catch {
return true; // Treat malformed tokens as expired
}
}
// Axios interceptor that auto-refreshes
axios.interceptors.request.use(async (config) => {
if (isTokenExpired(tokenManager.getAccessToken())) {
await refreshAccessToken();
}
config.headers.Authorization = `Bearer ${tokenManager.getAccessToken()}`;
return config;
});
Summary
OAuth 2.0 security comes down to a handful of critical practices:
| Practice | Why It Matters |
|---|---|
| Use authorization code + PKCE | Prevents code interception, replaces implicit flow |
| Validate state parameter | Prevents CSRF attacks against OAuth flows |
| Exact redirect_uri matching | Prevents authorization code leakage |
| Short-lived access tokens | Limits damage from token compromise |
| Secure token storage | Prevents XSS token theft |
| Minimal scopes | Reduces blast radius if compromised |
| Protect client secrets | Prevents impersonation of your application |
Getting these right isn't optional — OAuth misconfiguration consistently appears in security assessments of production applications. Run through this checklist every time you add a new OAuth integration.