OAuth 2.0 and OpenID Connect (OIDC) are the industry standards for API authentication and authorization. This guide covers everything you need to implement secure OAuth/OIDC flows, aligned with the latest OAuth 2.1 security best practices.
OAuth 2.0 vs OpenID Connect
┌─────────────────────────────────────────────────────────────────┐
│ OAUTH 2.0 vs OIDC │
├─────────────────────────────────────────────────────────────────┤
│ │
│ OAuth 2.0 = AUTHORIZATION │
│ "What can this app access?" │
│ └─► Access Tokens → API Access │
│ │
│ OpenID Connect = AUTHENTICATION + Authorization │
│ "Who is this user?" + "What can they access?" │
│ └─► ID Tokens → User Identity │
│ └─► Access Tokens → API Access │
│ │
│ OIDC = OAuth 2.0 + Identity Layer │
│ │
└─────────────────────────────────────────────────────────────────┘
Grant Type Selection Guide
What type of client are you building?
│
├─► User-facing app (web, mobile, SPA)
│ │
│ └─► Use: Authorization Code + PKCE
│ • User authenticates at IdP
│ • Code exchanged for tokens
│ • Works for all client types
│
├─► Server-to-server (no user)
│ │
│ └─► Use: Client Credentials
│ • Machine-to-machine auth
│ • Client authenticates directly
│ • No user involvement
│
├─► Device with no keyboard (TV, IoT)
│ │
│ └─► Use: Device Authorization Grant
│ • User authenticates on phone/computer
│ • Device polls for tokens
│ • Good for CLI tools too
│
└─► ❌ DEPRECATED (Don't use)
• Implicit Grant - tokens exposed in URL
• ROPC - user credentials shared with app
Authorization Code Flow with PKCE
This is the recommended flow for all user-facing applications.
Flow Diagram
┌─────────┐ ┌─────────┐
│ User │ │ IdP │
└────┬────┘ └────┬────┘
│ │
│ 1. Click "Login" │
▼ │
┌─────────┐ 2. Redirect with code_challenge ┌────┴────┐
│ App │ ─────────────────────────────────►│ Auth │
│ (Client)│ │Endpoint │
└────┬────┘ └────┬────┘
│ │
│ 3. User authenticates │
│ 4. User consents to scopes │
│ │
│ 5. Redirect with authorization code │
│◄─────────────────────────────────────────────│
│ │
│ 6. Exchange code + code_verifier ┌────┴────┐
│ ─────────────────────────────────────────►│ Token │
│ │Endpoint │
│ 7. Receive tokens └────┬────┘
│◄─────────────────────────────────────────────│
│ │
│ 8. Use access_token for API calls │
▼ ▼
┌─────────┐ ┌─────────┐
│ API │◄───────────────────────────────────│ Tokens │
└─────────┘ └─────────┘
Step 1: Generate PKCE Challenge
// Generate cryptographically random code verifier
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
// Create code challenge (SHA-256 hash of verifier)
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(new Uint8Array(hash));
}
// Base64URL encoding (no padding, URL-safe)
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// Generate both values
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier securely (session storage for SPAs, server session for web apps)
sessionStorage.setItem('pkce_verifier', codeVerifier);
Step 2: Build Authorization Request
function buildAuthorizationURL() {
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your_client_id',
redirect_uri: 'https://yourapp.com/callback',
scope: 'openid profile email read:data',
state: generateRandomString(32), // CSRF protection
nonce: generateRandomString(32), // ID token replay protection
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
// Store state for validation
sessionStorage.setItem('oauth_state', params.get('state'));
sessionStorage.setItem('oauth_nonce', params.get('nonce'));
return `https://auth.example.com/authorize?${params}`;
}
// Redirect user to authorization endpoint
window.location.href = buildAuthorizationURL();
Step 3: Handle Callback and Exchange Code
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
// Validate state to prevent CSRF
const storedState = sessionStorage.getItem('oauth_state');
if (params.get('state') !== storedState) {
throw new Error('State mismatch - possible CSRF attack');
}
// Check for errors
if (params.has('error')) {
throw new Error(`OAuth error: ${params.get('error_description')}`);
}
// Get authorization code
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('pkce_verifier');
// Exchange code for tokens
const tokens = await exchangeCode(code, codeVerifier);
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_nonce');
sessionStorage.removeItem('pkce_verifier');
return tokens;
}
async function exchangeCode(code, codeVerifier) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'your_client_id',
code: code,
redirect_uri: 'https://yourapp.com/callback',
code_verifier: codeVerifier
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${error.error_description}`);
}
return response.json();
// Returns: { access_token, refresh_token, id_token, expires_in, token_type }
}
Step 4: Validate ID Token (OIDC)
async function validateIdToken(idToken, nonce) {
// Decode token (without verification for inspection)
const parts = idToken.split('.');
const header = JSON.parse(atob(parts[0]));
const payload = JSON.parse(atob(parts[1]));
// Get signing keys from IdP
const jwksResponse = await fetch('https://auth.example.com/.well-known/jwks.json');
const jwks = await jwksResponse.json();
// Find the key matching the token's kid
const key = jwks.keys.find(k => k.kid === header.kid);
if (!key) {
throw new Error('Signing key not found');
}
// Verify signature (use a library like jose)
const isValid = await verifySignature(idToken, key);
if (!isValid) {
throw new Error('Invalid signature');
}
// Validate claims
const now = Math.floor(Date.now() / 1000);
if (payload.iss !== 'https://auth.example.com') {
throw new Error('Invalid issuer');
}
if (payload.aud !== 'your_client_id') {
throw new Error('Invalid audience');
}
if (payload.exp < now) {
throw new Error('Token expired');
}
if (payload.iat > now + 60) { // Allow 60s clock skew
throw new Error('Token issued in the future');
}
if (payload.nonce !== nonce) {
throw new Error('Invalid nonce - possible replay attack');
}
return payload; // User identity information
}
Client Credentials Flow
For server-to-server authentication without user involvement.
async function getClientCredentialsToken() {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
// Client authentication via Basic auth or in body
'Authorization': `Basic ${btoa(`${clientId}:${clientSecret}`)}`
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'read:data write:data'
})
});
const { access_token, expires_in } = await response.json();
// Cache token until near expiration
return access_token;
}
// Example: Service-to-service API call
async function callInternalAPI() {
const token = await getClientCredentialsToken();
const response = await fetch('https://api.internal.com/data', {
headers: {
'Authorization': `Bearer ${token}`
}
});
return response.json();
}
Token Management Best Practices
Secure Token Storage
| Platform | Access Token | Refresh Token |
|---|---|---|
| Web (SSR) | HttpOnly cookie | HttpOnly cookie (separate) |
| SPA | Memory only | HttpOnly cookie via BFF |
| Mobile | Secure storage | Keychain/Keystore |
| Server | Encrypted DB/secrets manager | Same, with higher protection |
Token Refresh with Rotation
async function refreshAccessToken(refreshToken) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: 'your_client_id',
refresh_token: refreshToken
})
});
if (!response.ok) {
if (response.status === 400) {
// Refresh token invalid/expired - require re-authentication
throw new Error('Session expired - please login again');
}
throw new Error('Token refresh failed');
}
const tokens = await response.json();
// IMPORTANT: Save the NEW refresh token (rotation)
// Old refresh token is now invalid
await saveTokens(tokens.access_token, tokens.refresh_token);
return tokens.access_token;
}
Automatic Token Refresh
class TokenManager {
constructor() {
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
}
async getAccessToken() {
// Check if token is expired or about to expire (60s buffer)
if (!this.accessToken || Date.now() >= this.expiresAt - 60000) {
await this.refresh();
}
return this.accessToken;
}
async refresh() {
if (!this.refreshToken) {
throw new Error('No refresh token - authentication required');
}
try {
const tokens = await refreshAccessToken(this.refreshToken);
this.setTokens(tokens);
} catch (error) {
// Clear tokens and redirect to login
this.clear();
throw error;
}
}
setTokens({ access_token, refresh_token, expires_in }) {
this.accessToken = access_token;
this.refreshToken = refresh_token;
this.expiresAt = Date.now() + (expires_in * 1000);
}
clear() {
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
}
}
// Usage with fetch wrapper
const tokenManager = new TokenManager();
async function authenticatedFetch(url, options = {}) {
const token = await tokenManager.getAccessToken();
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
}
Security Checklist
Authorization Request
- Use PKCE (code_challenge + code_challenge_method=S256)
- Include state parameter (random, >= 32 bytes)
- Include nonce parameter for OIDC (random, >= 32 bytes)
- Use exact redirect_uri matching
- Request minimum necessary scopes
Token Exchange
- Validate state parameter matches stored value
- Use code_verifier with PKCE
- Exchange code promptly (codes expire quickly)
- Use HTTPS for all token endpoint calls
Token Validation
- Verify signature using JWKS
- Validate iss (issuer) claim
- Validate aud (audience) claim
- Check exp (expiration) claim
- Validate nonce matches for ID tokens
- Use clock skew tolerance (e.g., 60 seconds)
Token Storage
- Never store tokens in localStorage (XSS vulnerable)
- Use HttpOnly cookies for web apps
- Use platform secure storage for mobile
- Encrypt tokens at rest on servers
Token Lifecycle
- Implement refresh token rotation
- Set reasonable expiration times
- Support token revocation
- Clear tokens on logout
Common Pitfalls
| Mistake | Risk | Solution |
|---|---|---|
| Skipping PKCE | Code interception | Always use PKCE, even for confidential clients |
| Tokens in localStorage | XSS theft | Use HttpOnly cookies or memory |
| Long-lived access tokens | Extended exposure | Short expiry (15 min) + refresh tokens |
| No state parameter | CSRF attacks | Random state, validate on callback |
| Trusting token claims | Forged tokens | Always verify signatures |
| Refresh token reuse | Undetected theft | Implement rotation |
Next Steps
- JWT Security Best Practices - Deep dive into JWT security
- API Authentication Comparison - When to use OAuth vs other methods
- API Security Complete Guide - Comprehensive API security overview

