Secure OAuth2 Implementation Checklist
OAuth2 is widely implemented and widely misimplemented. Most OAuth2 vulnerabilities are not flaws in the spec — they are mistakes in how developers integrate it. This guide gives you a practical checklist with code examples for each control point.
Before You Start: Flow Selection
Choosing the wrong OAuth2 flow is the first mistake. Use this decision tree:
Your application is:
├── Server-side web app (secret can be kept) → Authorization Code + PKCE
├── Single-page app (no server secret) → Authorization Code + PKCE
├── Mobile/native app → Authorization Code + PKCE
├── Trusted first-party with direct access → Resource Owner Password (avoid if possible)
└── Machine-to-machine / background service → Client Credentials
Never use the Implicit flow. It was deprecated in RFC 8252 for good reason — access tokens in URL fragments are visible in browser history, referrer headers, and server logs.
Checklist
1 — Use PKCE for All Public Clients
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. It’s required for SPAs and mobile apps and recommended everywhere.
// Node.js example — generating PKCE challenge
const crypto = require('crypto');
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(verifier) {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
// Store verifier in session; send challenge in 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', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', generateState()); // see #2
authUrl.searchParams.set('scope', 'openid profile email');
In the token exchange, include the original verifier:
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: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier, // the verifier, not the challenge
}),
});
Checklist item: [ ] PKCE with S256 challenge method used on all authorization code requests
2 — Validate State Parameter Against CSRF
The state parameter prevents CSRF attacks on the callback endpoint:
// Before redirecting to authorization server
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
// On callback — verify state before processing
app.get('/callback', (req, res) => {
const { code, state } = req.query;
if (!state || state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
delete req.session.oauthState;
// proceed with token exchange
});
Checklist item: [ ] State parameter generated cryptographically, validated on callback
3 — Validate Redirect URIs Exactly
// Authorization server side — STRICT comparison, no wildcards
const ALLOWED_REDIRECT_URIS = [
'https://app.example.com/callback',
'https://app.example.com/mobile-callback',
];
function validateRedirectUri(uri) {
// Exact match — no substring matching, no wildcards
if (!ALLOWED_REDIRECT_URIS.includes(uri)) {
throw new Error('Invalid redirect_uri');
}
// Also validate the URI itself is well-formed
const parsed = new URL(uri);
if (parsed.protocol !== 'https:') {
throw new Error('redirect_uri must use HTTPS');
}
}
Checklist items:
[ ] Redirect URIs registered explicitly — no wildcards
[ ] Redirect URI validated via exact string match, not partial
[ ] HTTP redirect URIs blocked (HTTPS only) in production
4 — Token Storage
Where you store tokens determines your attack surface:
// BAD: localStorage — accessible to XSS
localStorage.setItem('access_token', token); // DO NOT DO THIS
// BETTER: Memory only (lost on page refresh, but safest for SPAs)
let accessToken = null; // in-memory, never persisted
// BEST for server-side apps: HTTP-only cookies
res.cookie('access_token', token, {
httpOnly: true, // not accessible via document.cookie
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 3600 * 1000, // 1 hour
path: '/',
});
Checklist items:
[ ] Access tokens NOT stored in localStorage or sessionStorage
[ ] Tokens in HTTP-only, Secure, SameSite=Strict cookies OR memory
[ ] Refresh tokens stored server-side when possible
5 — Scope Minimization
Request only what you need:
// BAD — requesting everything
scope: 'read write admin delete'
// GOOD — request minimum needed
scope: 'openid profile email'
// For elevated operations, request incrementally
function requestElevatedScope() {
// Only request write scope when the user explicitly performs a write action
initiateAuthFlow({ scope: 'openid profile email write:documents' });
}
Checklist item: [ ] Scopes limited to minimum required for each use case
6 — Token Expiry and Rotation
// Short-lived access tokens (15 minutes to 1 hour)
// Refresh tokens with rotation
app.post('/token', async (req, res) => {
const { grant_type, refresh_token } = req.body;
if (grant_type === 'refresh_token') {
const oldToken = await db.refreshTokens.findOne({ token: refresh_token });
if (!oldToken || oldToken.used) {
// Refresh token reuse detected — revoke all user tokens
await db.refreshTokens.deleteMany({ userId: oldToken?.userId });
return res.status(401).json({ error: 'invalid_grant' });
}
// Mark old token as used
await db.refreshTokens.updateOne(
{ token: refresh_token },
{ $set: { used: true } }
);
// Issue new access token + new refresh token
const newAccessToken = generateAccessToken(oldToken.userId);
const newRefreshToken = generateRefreshToken(oldToken.userId);
res.json({
access_token: newAccessToken,
refresh_token: newRefreshToken,
expires_in: 900,
});
}
});
Checklist items:
[ ] Access token lifetime <= 1 hour
[ ] Refresh tokens rotated on each use
[ ] Refresh token reuse triggers revocation of all user tokens
[ ] Token revocation endpoint implemented
7 — ID Token Validation (OpenID Connect)
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
});
async function validateIdToken(idToken) {
const decoded = jwt.decode(idToken, { complete: true });
const key = await client.getSigningKey(decoded.header.kid);
const publicKey = key.getPublicKey();
const payload = jwt.verify(idToken, publicKey, {
algorithms: ['RS256'], // reject HS256 and 'none'
audience: CLIENT_ID, // validate aud claim
issuer: 'https://auth.example.com', // validate iss claim
});
// Also check nonce if you sent one
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch');
}
return payload;
}
Checklist items:
[ ] ID token signature verified against IdP's public keys
[ ] aud claim validated against your client_id
[ ] iss claim validated against expected issuer
[ ] algorithm restricted (never accept 'none' or HS256 with RS256 IdPs)
[ ] nonce validated if sent
Full Security Checklist Summary
Authorization Code Flow
[ ] PKCE with S256 used for all public clients
[ ] State parameter validated to prevent CSRF
[ ] Redirect URIs registered and validated exactly
Token Handling
[ ] Access tokens not in localStorage
[ ] Refresh tokens rotated on use
[ ] Token expiry enforced (access < 1h, refresh < 30d)
[ ] Revocation endpoint implemented and used on logout
Client Configuration
[ ] Client secrets rotated periodically and stored in secrets manager
[ ] Scopes minimized per use case
[ ] Implicit flow not used anywhere
Server Validation
[ ] ID tokens validated (signature, aud, iss, nonce)
[ ] Authorization codes single-use
[ ] Codes expire within 10 minutes
Related Reading
- Secure GraphQL API Hardening Guide
- Secure CORS Configuration Guide
- Secure WebRTC Implementation Guide
Built by theluckystrike — More at zovo.one