API Security: OAuth 2.0 Deep Dive

OAuth 2.0 is everywhere. It powers "Sign in with Google," protects your banking API, and secures microservice communication. But it's also widely misunderstood and frequently misimplemented. This guide cuts through the confusion. We'll walk through each OAuth flow with real code, explain when to use each one, and cover

TRY NANO BANANA FOR FREE

API Security: OAuth 2.0 Deep Dive

TRY NANO BANANA FOR FREE
Contents

OAuth 2.0 is everywhere. It powers "Sign in with Google," protects your banking API, and secures microservice communication. But it's also widely misunderstood and frequently misimplemented.

This guide cuts through the confusion. We'll walk through each OAuth flow with real code, explain when to use each one, and cover the mistakes that lead to security vulnerabilities.

What OAuth 2.0 Actually Does

OAuth 2.0 is an authorization framework, not an authentication protocol. That distinction matters.

  • Authentication: proving who you are
  • Authorization: proving what you're allowed to do

OAuth 2.0 answers the question: "Can this application access this resource on behalf of this user?"

The four main actors:

  • Resource Owner: the user who owns the data
  • Client: the application requesting access
  • Authorization Server: issues tokens (e.g., Auth0, Okta, your own server)
  • Resource Server: the API holding the protected data

The Authorization Code Flow

This is the most common and most secure flow. Use it for any application where a user logs in.

Here's the full flow for our PetStore app:

1. User clicks "Login with PetStore"
2. App redirects user to authorization server
3. User logs in and approves access
4. Authorization server redirects back with a code
5. App exchanges code for tokens (server-side)
6. App uses access token to call the API

Step 1 — Build the authorization URL:

const crypto = require('crypto');
const querystring = require('querystring');

function buildAuthorizationUrl() {
  const state = crypto.randomBytes(16).toString('hex');

  // Store state in session to verify later
  session.oauthState = state;

  const params = querystring.stringify({
    response_type: 'code',
    client_id: process.env.CLIENT_ID,
    redirect_uri: 'https://app.petstore.com/callback',
    scope: 'pets:read pets:write adoptions:create',
    state: state
  });

  return `https://auth.petstore.com/authorize?${params}`;
}

// Redirect user
app.get('/login', (req, res) => {
  res.redirect(buildAuthorizationUrl());
});

Step 2 — Handle the callback:

app.get('/callback', async (req, res) => {
  const { code, state, error } = req.query;

  // Always check for errors first
  if (error) {
    return res.status(400).json({
      error: 'Authorization failed',
      details: error
    });
  }

  // Validate state to prevent CSRF attacks
  if (state !== req.session.oauthState) {
    return res.status(400).json({
      error: 'Invalid state parameter'
    });
  }

  // Exchange code for tokens
  try {
    const tokens = await exchangeCodeForTokens(code);
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;
    req.session.tokenExpiry = Date.now() + (tokens.expires_in * 1000);

    res.redirect('/dashboard');
  } catch (err) {
    res.status(500).json({ error: 'Token exchange failed' });
  }
});

async function exchangeCodeForTokens(code) {
  const response = await fetch('https://auth.petstore.com/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      // Use HTTP Basic auth for client credentials
      'Authorization': `Basic ${Buffer.from(
        `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
      ).toString('base64')}`
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://app.petstore.com/callback'
    })
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error_description || 'Token exchange failed');
  }

  return response.json();
}

Step 3 — Use the access token:

async function getPets(accessToken) {
  const response = await fetch('https://api.petstore.com/pets', {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/json'
    }
  });

  if (response.status === 401) {
    throw new Error('Token expired or invalid');
  }

  return response.json();
}

PKCE: Securing Public Clients

The authorization code flow has a problem for public clients (mobile apps, SPAs): you can't safely store a client secret. Anyone can decompile your app and find it.

PKCE (Proof Key for Code Exchange, pronounced "pixie") solves this. Instead of a client secret, you use a cryptographic challenge.

Here's how it works:

// 1. Generate a code verifier (random string)
function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

// 2. Create a code challenge from the verifier
function generateCodeChallenge(verifier) {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

// 3. Store verifier, send challenge
async function startPKCEFlow() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);
  const state = crypto.randomBytes(16).toString('hex');

  // Store verifier securely (sessionStorage for SPAs)
  sessionStorage.setItem('code_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'your-spa-client-id',
    redirect_uri: 'https://app.petstore.com/callback',
    scope: 'pets:read adoptions:create',
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256'
  });

  window.location.href = `https://auth.petstore.com/authorize?${params}`;
}

// 4. Exchange code using verifier (no secret needed)
async function handleCallback(code, state) {
  const storedState = sessionStorage.getItem('oauth_state');
  const codeVerifier = sessionStorage.getItem('code_verifier');

  if (state !== storedState) {
    throw new Error('State mismatch - possible CSRF attack');
  }

  const response = await fetch('https://auth.petstore.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'your-spa-client-id',
      code: code,
      redirect_uri: 'https://app.petstore.com/callback',
      code_verifier: codeVerifier  // Proves you started the flow
    })
  });

  return response.json();
}

The authorization server verifies that sha256(code_verifier) === code_challenge. Even if someone intercepts the authorization code, they can't exchange it without the verifier.

Always use PKCE for: - Single-page applications - Mobile apps - Desktop apps - Any client that can't keep a secret

Client Credentials Flow

For machine-to-machine communication where there's no user involved, use client credentials. This is common for microservices, background jobs, and server-to-server APIs.

class ServiceTokenManager {
  constructor(clientId, clientSecret, tokenUrl) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.tokenUrl = tokenUrl;
    this.token = null;
    this.tokenExpiry = null;
  }

  async getToken() {
    // Return cached token if still valid (with 60s buffer)
    if (this.token && Date.now() < this.tokenExpiry - 60000) {
      return this.token;
    }

    return this.fetchNewToken();
  }

  async fetchNewToken() {
    const response = await fetch(this.tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Basic ${Buffer.from(
          `${this.clientId}:${this.clientSecret}`
        ).toString('base64')}`
      },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        scope: 'pets:read pets:write'
      })
    });

    if (!response.ok) {
      throw new Error('Failed to obtain service token');
    }

    const data = await response.json();
    this.token = data.access_token;
    this.tokenExpiry = Date.now() + (data.expires_in * 1000);

    return this.token;
  }

  async callApi(url, options = {}) {
    const token = await this.getToken();

    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`
      }
    });
  }
}

// Usage in a microservice
const tokenManager = new ServiceTokenManager(
  process.env.SERVICE_CLIENT_ID,
  process.env.SERVICE_CLIENT_SECRET,
  'https://auth.petstore.com/token'
);

// Automatically handles token refresh
const response = await tokenManager.callApi(
  'https://api.petstore.com/internal/pets/stats'
);

Refresh Tokens

Access tokens are short-lived (typically 15 minutes to 1 hour). Refresh tokens let you get new access tokens without making the user log in again.

class TokenStore {
  constructor() {
    this.accessToken = null;
    this.refreshToken = null;
    this.accessTokenExpiry = null;
  }

  async getValidAccessToken() {
    // Token still valid
    if (this.accessToken && Date.now() < this.accessTokenExpiry - 30000) {
      return this.accessToken;
    }

    // Try to refresh
    if (this.refreshToken) {
      return this.refreshAccessToken();
    }

    throw new Error('No valid token - user must log in');
  }

  async refreshAccessToken() {
    const response = await fetch('https://auth.petstore.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: 'refresh_token',
        refresh_token: this.refreshToken
      })
    });

    if (!response.ok) {
      // Refresh token expired or revoked - user must log in again
      this.clearTokens();
      throw new Error('Session expired - please log in again');
    }

    const tokens = await response.json();
    this.accessToken = tokens.access_token;
    this.accessTokenExpiry = Date.now() + (tokens.expires_in * 1000);

    // Some servers issue a new refresh token (refresh token rotation)
    if (tokens.refresh_token) {
      this.refreshToken = tokens.refresh_token;
    }

    return this.accessToken;
  }

  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    this.accessTokenExpiry = null;
  }
}

// Middleware that handles token refresh automatically
async function apiRequest(url, options = {}) {
  const tokenStore = getTokenStore(); // Get from session/storage

  try {
    const token = await tokenStore.getValidAccessToken();

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`
      }
    });

    if (response.status === 401) {
      // Token was rejected - clear and redirect to login
      tokenStore.clearTokens();
      redirectToLogin();
      return;
    }

    return response;
  } catch (error) {
    if (error.message.includes('log in')) {
      redirectToLogin();
    }
    throw error;
  }
}

Refresh token rotation is a security best practice: each time you use a refresh token, you get a new one. If an old refresh token is used, the authorization server detects a potential theft and revokes all tokens.

Token Introspection

How does your API verify that an access token is valid? There are two approaches.

JWT Tokens (Self-Contained)

JWTs contain all the information needed to verify them locally:

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

// Fetch public keys from authorization server
const client = jwksClient({
  jwksUri: 'https://auth.petstore.com/.well-known/jwks.json',
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 600000 // 10 minutes
});

function getSigningKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

// Middleware to verify JWT
function requireAuth(requiredScopes = []) {
  return async (req, res, next) => {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing authorization header' });
    }

    const token = authHeader.slice(7);

    try {
      const decoded = await new Promise((resolve, reject) => {
        jwt.verify(token, getSigningKey, {
          audience: 'https://api.petstore.com',
          issuer: 'https://auth.petstore.com',
          algorithms: ['RS256']
        }, (err, decoded) => {
          if (err) reject(err);
          else resolve(decoded);
        });
      });

      // Check required scopes
      const tokenScopes = decoded.scope ? decoded.scope.split(' ') : [];
      const hasScopes = requiredScopes.every(s => tokenScopes.includes(s));

      if (!hasScopes) {
        return res.status(403).json({
          error: 'Insufficient scope',
          required: requiredScopes,
          provided: tokenScopes
        });
      }

      req.user = decoded;
      next();
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'Token expired' });
      }
      return res.status(401).json({ error: 'Invalid token' });
    }
  };
}

// Usage
app.get('/api/pets',
  requireAuth(['pets:read']),
  async (req, res) => {
    const pets = await getPets();
    res.json(pets);
  }
);

app.post('/api/adoptions',
  requireAuth(['adoptions:create']),
  async (req, res) => {
    const adoption = await createAdoption(req.body, req.user.sub);
    res.json(adoption);
  }
);

Opaque Tokens (Reference Tokens)

Opaque tokens are just random strings. Your API must call the authorization server to validate them:

const NodeCache = require('node-cache');

// Cache introspection results to avoid hammering the auth server
const introspectionCache = new NodeCache({ stdTTL: 30 });

async function introspectToken(token) {
  // Check cache first
  const cached = introspectionCache.get(token);
  if (cached !== undefined) {
    return cached;
  }

  const response = await fetch('https://auth.petstore.com/introspect', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': `Basic ${Buffer.from(
        `${process.env.RESOURCE_SERVER_ID}:${process.env.RESOURCE_SERVER_SECRET}`
      ).toString('base64')}`
    },
    body: new URLSearchParams({ token })
  });

  const result = await response.json();

  // Cache the result (but not for too long)
  introspectionCache.set(token, result, result.active ? 30 : 5);

  return result;
}

function requireAuthOpaque(requiredScopes = []) {
  return async (req, res, next) => {
    const authHeader = req.headers.authorization;

    if (!authHeader?.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing token' });
    }

    const token = authHeader.slice(7);
    const tokenInfo = await introspectToken(token);

    if (!tokenInfo.active) {
      return res.status(401).json({ error: 'Token inactive or expired' });
    }

    const tokenScopes = tokenInfo.scope ? tokenInfo.scope.split(' ') : [];
    const hasScopes = requiredScopes.every(s => tokenScopes.includes(s));

    if (!hasScopes) {
      return res.status(403).json({ error: 'Insufficient scope' });
    }

    req.user = tokenInfo;
    next();
  };
}

JWT vs Opaque: Which to Use?

JWT Opaque
Validation Local (fast) Remote call (slower)
Revocation Hard (wait for expiry) Immediate
Size Large (100-500 bytes) Small (20-40 bytes)
Privacy Claims visible to anyone Claims stay on server
Best for Stateless APIs, microservices When immediate revocation matters

For most APIs, JWTs with short expiry (15 minutes) and refresh tokens strike the right balance.

Common OAuth Mistakes

1. Not Validating the State Parameter

// WRONG - vulnerable to CSRF
app.get('/callback', async (req, res) => {
  const { code } = req.query;
  const tokens = await exchangeCode(code);
  // ...
});

// RIGHT - always validate state
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;

  if (state !== req.session.oauthState) {
    return res.status(400).json({ error: 'Invalid state' });
  }

  const tokens = await exchangeCode(code);
  // ...
});

2. Storing Tokens in localStorage

// WRONG - vulnerable to XSS
localStorage.setItem('access_token', tokens.access_token);

// RIGHT - use httpOnly cookies for web apps
res.cookie('access_token', tokens.access_token, {
  httpOnly: true,    // Not accessible via JavaScript
  secure: true,      // HTTPS only
  sameSite: 'strict', // CSRF protection
  maxAge: 3600000    // 1 hour
});

3. Not Checking Token Audience

// WRONG - accepts tokens meant for other APIs
jwt.verify(token, publicKey, {
  issuer: 'https://auth.petstore.com'
});

// RIGHT - always validate audience
jwt.verify(token, publicKey, {
  issuer: 'https://auth.petstore.com',
  audience: 'https://api.petstore.com'  // Must match your API
});

4. Overly Broad Scopes

// WRONG - requesting everything
scope: 'admin:all'

// RIGHT - request minimum needed
scope: 'pets:read adoptions:create'

5. Not Handling Token Expiry

// WRONG - assumes token is always valid
async function getPets() {
  return fetch('/api/pets', {
    headers: { 'Authorization': `Bearer ${storedToken}` }
  });
}

// RIGHT - check expiry and refresh
async function getPets() {
  const token = await tokenManager.getValidAccessToken();
  return fetch('/api/pets', {
    headers: { 'Authorization': `Bearer ${token}` }
  });
}

6. Logging Tokens

// WRONG - tokens in logs are a security incident
console.log('Request headers:', req.headers);
logger.info('Token exchange response:', tokenResponse);

// RIGHT - redact sensitive values
logger.info('Token exchange successful', {
  userId: decoded.sub,
  scopes: decoded.scope,
  // Never log the actual token
});

7. Using Implicit Flow

The implicit flow (where tokens are returned directly in the URL fragment) is deprecated. Use authorization code + PKCE instead:

// WRONG - implicit flow (deprecated)
response_type: 'token'

// RIGHT - authorization code with PKCE
response_type: 'code'
code_challenge: codeChallenge
code_challenge_method: 'S256'

Building an Authorization Server

If you need to build your own OAuth server (rather than using Auth0, Okta, etc.), here's a minimal implementation:

const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');

const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// In production, use a real database
const authCodes = new Map();
const refreshTokens = new Map();

// Authorization endpoint
app.get('/authorize', (req, res) => {
  const { client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;

  // Validate client
  const client = getClient(client_id);
  if (!client || !client.redirectUris.includes(redirect_uri)) {
    return res.status(400).json({ error: 'invalid_client' });
  }

  // Show login/consent page (simplified)
  res.send(`
    <form method="POST" action="/authorize/approve">
      <input type="hidden" name="client_id" value="${client_id}">
      <input type="hidden" name="redirect_uri" value="${redirect_uri}">
      <input type="hidden" name="scope" value="${scope}">
      <input type="hidden" name="state" value="${state}">
      <input type="hidden" name="code_challenge" value="${code_challenge}">
      <input type="hidden" name="code_challenge_method" value="${code_challenge_method}">
      <button type="submit">Approve</button>
    </form>
  `);
});

app.post('/authorize/approve', (req, res) => {
  const { client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.body;

  // Generate authorization code
  const code = crypto.randomBytes(32).toString('base64url');

  authCodes.set(code, {
    clientId: client_id,
    redirectUri: redirect_uri,
    scope,
    userId: 'user-123', // From authenticated session
    codeChallenge: code_challenge,
    codeChallengeMethod: code_challenge_method,
    expiresAt: Date.now() + 600000 // 10 minutes
  });

  const params = new URLSearchParams({ code, state });
  res.redirect(`${redirect_uri}?${params}`);
});

// Token endpoint
app.post('/token', (req, res) => {
  const { grant_type } = req.body;

  if (grant_type === 'authorization_code') {
    return handleAuthorizationCode(req, res);
  }

  if (grant_type === 'refresh_token') {
    return handleRefreshToken(req, res);
  }

  if (grant_type === 'client_credentials') {
    return handleClientCredentials(req, res);
  }

  res.status(400).json({ error: 'unsupported_grant_type' });
});

function handleAuthorizationCode(req, res) {
  const { code, redirect_uri, code_verifier } = req.body;

  const codeData = authCodes.get(code);

  if (!codeData || Date.now() > codeData.expiresAt) {
    return res.status(400).json({ error: 'invalid_grant' });
  }

  // Verify PKCE
  if (codeData.codeChallenge) {
    const challenge = crypto
      .createHash('sha256')
      .update(code_verifier)
      .digest('base64url');

    if (challenge !== codeData.codeChallenge) {
      return res.status(400).json({ error: 'invalid_grant' });
    }
  }

  // Delete used code (one-time use)
  authCodes.delete(code);

  const tokens = generateTokens(codeData.userId, codeData.scope);
  res.json(tokens);
}

function generateTokens(userId, scope) {
  const accessToken = jwt.sign(
    {
      sub: userId,
      scope,
      aud: 'https://api.petstore.com',
      iss: 'https://auth.petstore.com'
    },
    process.env.JWT_PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '15m' }
  );

  const refreshToken = crypto.randomBytes(32).toString('base64url');
  refreshTokens.set(refreshToken, {
    userId,
    scope,
    expiresAt: Date.now() + (30 * 24 * 60 * 60 * 1000) // 30 days
  });

  return {
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 900,
    refresh_token: refreshToken,
    scope
  };
}

Wrapping Up

OAuth 2.0 is powerful but has sharp edges. The key takeaways:

  • Use authorization code + PKCE for user-facing apps
  • Use client credentials for machine-to-machine
  • Always validate state to prevent CSRF
  • Store tokens in httpOnly cookies, not localStorage
  • Use short-lived access tokens with refresh tokens
  • Validate audience and issuer in JWTs
  • Request minimum required scopes
  • Never log tokens

Get these right and your API security will be solid. Get them wrong and you're one XSS vulnerability away from a breach.

When in doubt, use a battle-tested library or managed service rather than rolling your own. The OAuth spec has many subtle requirements that are easy to miss.