API Security14 min read

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.

AS

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.