OAuth 2.0: Authorization Code Flow Explained

OAuth 2.0 solves a fundamental problem: how do you grant a third-party application access to a user's resources without sharing the user's credentials? Before OAuth, users would hand over their...

Key Insights

  • Authorization Code Flow is the most secure OAuth 2.0 flow for web applications because it never exposes access tokens to the browser—only a temporary authorization code that’s exchanged server-side
  • PKCE (Proof Key for Code Exchange) is now recommended even for confidential clients to prevent authorization code interception attacks, making it essential for modern implementations
  • The state parameter isn’t optional—it’s your primary defense against CSRF attacks and should be a cryptographically random value tied to the user’s session

Understanding OAuth 2.0 and Authorization Code Flow

OAuth 2.0 solves a fundamental problem: how do you grant a third-party application access to a user’s resources without sharing the user’s credentials? Before OAuth, users would hand over their username and password to third-party apps—a security nightmare.

The Authorization Code Flow is the gold standard for web applications with a backend server. Unlike implicit flow (deprecated) or client credentials flow (for machine-to-machine), this flow keeps access tokens away from the browser, significantly reducing attack surface.

Four key players participate in this dance:

  • Resource Owner: The user who owns the data
  • Client: Your application requesting access
  • Authorization Server: The service that authenticates the user and issues tokens (e.g., Google, GitHub)
  • Resource Server: The API that hosts the protected resources

The beauty of this flow is the separation of concerns. Your application never sees the user’s password, and the authorization server never sees your application’s backend code.

The Authorization Code Flow Step-by-Step

Here’s how the complete flow works:

// Step 1: User clicks "Login with Provider" in your app
// Your app redirects to authorization server

// Step 2: Authorization server shows login page
// User authenticates with their credentials
// User grants consent to requested permissions

// Step 3: Authorization server redirects back to your app
// URL: https://yourapp.com/callback?code=AUTH_CODE&state=STATE_VALUE

// Step 4: Your backend exchanges the code for tokens
// POST to token endpoint with code + client credentials

// Step 5: Authorization server returns tokens
// Response: { access_token, refresh_token, expires_in, token_type }

// Step 6: Your app uses access_token to call APIs
// Authorization: Bearer ACCESS_TOKEN

The critical security feature: the authorization code is useless without your client secret (or PKCE verifier), which only your backend knows. Even if an attacker intercepts the code, they can’t exchange it for tokens.

Building the Authorization Request

The authorization URL must include specific parameters. Missing or incorrect parameters will cause the flow to fail.

import crypto from 'crypto';

function generateAuthorizationUrl(config) {
  // Generate cryptographically secure state value
  const state = crypto.randomBytes(32).toString('hex');
  
  // Store state in session for later validation
  // (in production, use Redis or session store)
  global.pendingStates = global.pendingStates || new Map();
  global.pendingStates.set(state, {
    timestamp: Date.now(),
    // Store any app state you need to restore after callback
    returnTo: config.returnTo || '/'
  });

  const params = new URLSearchParams({
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    response_type: 'code',
    scope: config.scope || 'read:user user:email',
    state: state,
    // Some providers support additional parameters
    access_type: 'offline', // Request refresh token
    prompt: 'consent' // Force consent screen
  });

  return `${config.authorizationEndpoint}?${params.toString()}`;
}

// Usage
const authUrl = generateAuthorizationUrl({
  clientId: process.env.OAUTH_CLIENT_ID,
  redirectUri: 'https://yourapp.com/auth/callback',
  authorizationEndpoint: 'https://provider.com/oauth/authorize',
  scope: 'read:user user:email',
  returnTo: '/dashboard'
});

// Redirect user to authUrl

The state parameter is non-negotiable. It prevents CSRF attacks where an attacker tricks a user into authorizing access to the attacker’s account. Always generate a unique, unpredictable value and validate it on callback.

Handling the Callback and Token Exchange

When the authorization server redirects back to your application, you must validate the state and exchange the code for tokens.

import express from 'express';
import crypto from 'crypto';

const app = express();

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

  // Handle authorization denial
  if (error) {
    return res.status(400).send(`Authorization failed: ${error}`);
  }

  // Validate state parameter (CSRF protection)
  const storedState = global.pendingStates?.get(state);
  if (!storedState) {
    return res.status(400).send('Invalid state parameter');
  }

  // Clean up used state
  global.pendingStates.delete(state);

  // Check state hasn't expired (5 minutes)
  if (Date.now() - storedState.timestamp > 5 * 60 * 1000) {
    return res.status(400).send('State expired');
  }

  try {
    // Exchange authorization code for tokens
    const tokenResponse = await fetch('https://provider.com/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': 'application/json'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: process.env.OAUTH_REDIRECT_URI,
        client_id: process.env.OAUTH_CLIENT_ID,
        client_secret: process.env.OAUTH_CLIENT_SECRET
      })
    });

    if (!tokenResponse.ok) {
      const error = await tokenResponse.text();
      throw new Error(`Token exchange failed: ${error}`);
    }

    const tokens = await tokenResponse.json();
    // tokens = { access_token, refresh_token, expires_in, token_type }

    // Store tokens securely (encrypted session, database, etc.)
    req.session.tokens = {
      accessToken: tokens.access_token,
      refreshToken: tokens.refresh_token,
      expiresAt: Date.now() + (tokens.expires_in * 1000)
    };

    // Redirect to original destination
    res.redirect(storedState.returnTo);

  } catch (error) {
    console.error('Token exchange error:', error);
    res.status(500).send('Authentication failed');
  }
});

Never expose this endpoint to CORS or make it accept POST requests from browsers. It should only handle GET requests from server-side redirects.

Using Access Tokens and Refresh Flow

Once you have an access token, use it to make authenticated API requests. Implement automatic token refresh to handle expiration gracefully.

class OAuthClient {
  constructor(tokens, config) {
    this.tokens = tokens;
    this.config = config;
  }

  async request(url, options = {}) {
    // Check if token is expired
    if (Date.now() >= this.tokens.expiresAt - 60000) {
      await this.refreshAccessToken();
    }

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${this.tokens.accessToken}`,
        'Accept': 'application/json'
      }
    });

    // Handle token expiration during request
    if (response.status === 401) {
      await this.refreshAccessToken();
      // Retry request with new token
      return this.request(url, options);
    }

    return response;
  }

  async refreshAccessToken() {
    if (!this.tokens.refreshToken) {
      throw new Error('No refresh token available');
    }

    const response = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: this.tokens.refreshToken,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret
      })
    });

    if (!response.ok) {
      throw new Error('Token refresh failed');
    }

    const newTokens = await response.json();
    
    this.tokens.accessToken = newTokens.access_token;
    this.tokens.expiresAt = Date.now() + (newTokens.expires_in * 1000);
    
    // Some providers rotate refresh tokens
    if (newTokens.refresh_token) {
      this.tokens.refreshToken = newTokens.refresh_token;
    }

    // Persist updated tokens
    await this.saveTokens();
  }

  async saveTokens() {
    // Implement token persistence (database, encrypted session, etc.)
  }
}

// Usage
const client = new OAuthClient(req.session.tokens, {
  tokenEndpoint: 'https://provider.com/oauth/token',
  clientId: process.env.OAUTH_CLIENT_ID,
  clientSecret: process.env.OAUTH_CLIENT_SECRET
});

const userResponse = await client.request('https://api.provider.com/user');
const userData = await userResponse.json();

Security Best Practices

PKCE (Proof Key for Code Exchange) adds an additional layer of security by preventing authorization code interception attacks. It’s now recommended for all clients, not just public clients.

import crypto from 'crypto';

function generatePKCEChallenge() {
  // Generate code verifier (random string)
  const codeVerifier = crypto
    .randomBytes(32)
    .toString('base64url');

  // Generate code challenge (SHA256 hash of verifier)
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  return {
    codeVerifier,
    codeChallenge,
    codeChallengeMethod: 'S256'
  };
}

// During authorization request
const pkce = generatePKCEChallenge();

// Store code_verifier in session
req.session.codeVerifier = pkce.codeVerifier;

// Add to authorization URL
const params = new URLSearchParams({
  // ... other parameters
  code_challenge: pkce.codeChallenge,
  code_challenge_method: pkce.codeChallengeMethod
});

// During token exchange
const tokenResponse = await fetch(tokenEndpoint, {
  method: 'POST',
  body: new URLSearchParams({
    // ... other parameters
    code_verifier: req.session.codeVerifier
  })
});

Additional critical security measures:

  • Always use HTTPS in production—OAuth over HTTP is completely insecure
  • Store tokens encrypted at rest, never in localStorage or cookies without HttpOnly flag
  • Validate redirect_uri strictly—don’t allow open redirects
  • Implement token rotation for refresh tokens
  • Set minimal scopes—only request permissions you actually need
  • Use short-lived access tokens (15-60 minutes) with refresh tokens for long-term access

Practical Implementation with GitHub

Here’s a complete minimal implementation using GitHub OAuth:

import express from 'express';
import session from 'express-session';
import crypto from 'crypto';

const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true }
}));

// Environment variables needed:
// GITHUB_CLIENT_ID
// GITHUB_CLIENT_SECRET
// SESSION_SECRET

app.get('/login', (req, res) => {
  const state = crypto.randomBytes(32).toString('hex');
  req.session.oauthState = state;

  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID,
    redirect_uri: 'http://localhost:3000/auth/callback',
    scope: 'read:user user:email',
    state: state
  });

  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

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

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

  delete req.session.oauthState;

  try {
    const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      body: JSON.stringify({
        client_id: process.env.GITHUB_CLIENT_ID,
        client_secret: process.env.GITHUB_CLIENT_SECRET,
        code: code
      })
    });

    const tokens = await tokenResponse.json();
    req.session.accessToken = tokens.access_token;

    // Fetch user info
    const userResponse = await fetch('https://api.github.com/user', {
      headers: { 'Authorization': `Bearer ${tokens.access_token}` }
    });
    
    const user = await userResponse.json();
    req.session.user = user;

    res.redirect('/dashboard');
  } catch (error) {
    console.error(error);
    res.status(500).send('Authentication failed');
  }
});

app.get('/dashboard', (req, res) => {
  if (!req.session.user) {
    return res.redirect('/login');
  }
  res.json({ user: req.session.user });
});

app.listen(3000);

This implementation covers the essentials: state validation, token exchange, and authenticated requests. For production, add PKCE, proper error handling, token refresh logic, and secure token storage with encryption.

OAuth 2.0 Authorization Code Flow is complex, but understanding each component and implementing proper security measures will give you a robust, secure authentication system that protects both your users and your application.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.