Skip to main content

Overview

Security is paramount when implementing OAuth 2.0. This guide covers essential security practices, common vulnerabilities, and how to protect your application and users from attacks.
Production Requirement: Review and implement ALL security practices in this guide before deploying to production.
OAuth 2.0 is fundamentally about user empowerment and consent. Security extends beyond protecting tokens—it includes respecting the authorization boundaries users establish.

User Agency First

Users grant your application permission to act on their behalf. This authorization is not blanket access—it’s permission to perform specific, expected actions.

Principle of Least Privilege

Only request and use the minimum permissions necessary. Just because you have a token doesn’t mean you should access all available user data.

Transparent Intent

Users should always understand what actions your application will perform on their behalf. Unexpected behavior erodes trust and violates consent.

Revocation Rights

Users must be able to revoke access at any time. Implement clear logout flows and honor token revocation immediately.
Critical Security Principle: Obtaining an access token grants you permission to act on behalf of the user—not permission to access or modify their data without their explicit understanding and consent for each type of action.

PKCE Implementation

PKCE (Proof Key for Code Exchange, pronounced “pixie”) is mandatory for all OAuth flows. It prevents authorization code interception attacks.

What is PKCE?

PKCE adds a cryptographic challenge to the OAuth flow:
  1. Code Verifier: Random 43-128 character string
  2. Code Challenge: SHA256 hash of the code verifier
  3. Verification: Server validates verifier matches challenge

Implementation

import crypto from 'crypto';

function generateCodeVerifier(): string {
  // Generate 32 random bytes, base64url encode
  // Result: 43 characters (meets 43-128 requirement)
  return crypto
    .randomBytes(32)
    .toString('base64url');
}

function generateCodeChallenge(verifier: string): string {
  // Hash with SHA256, base64url encode
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

// Example usage
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

console.log('Code Verifier:', codeVerifier);
console.log('Code Challenge:', codeChallenge);

// Store verifier securely for Step 4
sessionStorage.setItem('code_verifier', codeVerifier);

PKCE Requirements

Code Verifier

  • Length: 43-128 characters
  • Character set: [A-Z, a-z, 0-9, -, ., _, ~]
  • Cryptographically random
  • Single use only

Code Challenge

  • Algorithm: SHA256
  • Encoding: base64url (no padding)
  • Method: Must be S256
  • Derived from verifier
Common Mistake: Using plain text as code_challenge_method. Always use S256 (SHA256), never plain.

State Parameter (CSRF Protection)

The state parameter prevents Cross-Site Request Forgery (CSRF) attacks.

How It Works

  1. Generate: Create random state before initiating OAuth
  2. Send: Include in OAuth initiation request
  3. Verify: Validate returned state matches original
import crypto from 'crypto';

function generateState(): string {
  // Generate cryptographically random state
  return crypto.randomBytes(16).toString('hex');
}

// Step 1: Generate and store state
const state = generateState();
sessionStorage.setItem('oauth_state', state);

// Include in OAuth initiation
const params = new URLSearchParams({
  state,
  // ... other params
});

// Step 3: Verify state on callback
function handleCallback(returnedState: string) {
  const originalState = sessionStorage.getItem('oauth_state');

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

  // Clean up
  sessionStorage.removeItem('oauth_state');

  // Continue with token exchange
}
State Requirements:
  • Minimum 16 characters
  • Cryptographically random
  • Stored securely (session, not localStorage)
  • Validated exactly (case-sensitive)
  • Single use only

Secret Key Management

Your x-secret-key is highly sensitive and must be protected.

Do’s and Don’ts

Store Secrets Securely
// Environment variables
const secretKey = process.env.SECRET_KEY;

// Server-side only
app.post('/api/oauth/initiate', async (req, res) => {
  const response = await fetch(oauthURL, {
    headers: {
      'x-secret-key': process.env.SECRET_KEY
    }
  });
});

// Use secret management services
// - AWS Secrets Manager
// - HashiCorp Vault
// - Azure Key Vault
Rotate Regularly
  • Rotate secrets every 90 days
  • Rotate immediately if compromised
  • Use different keys per environment

Secret Detection

Add pre-commit hooks to prevent accidental commits:
#!/bin/bash

# Check for potential secrets
if git diff --cached | grep -E '(x-secret-key|SECRET_KEY|sk_live_|sk_test_)'; then
    echo "Error: Potential secret detected in commit"
    echo "Please remove secrets before committing"
    exit 1
fi

Redirect URI Security

The redirect URI (or callback URL) is where users return after authentication, and it’s critical for OAuth security. Incorrect configuration can lead to authorization code theft.
What You’ll Learn: This section covers redirect URI requirements, HTTPS vs HTTP, custom schemes for mobile apps, CORS considerations, and validation techniques. For a high-level overview, see the OAuth Quick Start.

What is a Redirect URI?

A redirect URI serves as the callback endpoint where the authorization server returns the user along with the authorization code after successful authentication.

Allowed URI Schemes

Web applications in production environments MUST use HTTPS
✅ https://app.example.com/oauth/callback
✅ https://dashboard.example.com/auth/callback
✅ https://api.example.com/v1/oauth/redirect
Production Requirement: HTTP redirect URIs are rejected in production. HTTPS ensures authorization codes are encrypted during transmission, preventing interception attacks.
HTTPS Requirements:
  • Valid SSL/TLS certificate
  • TLS 1.2 or higher
  • Proper certificate chain configuration
  • No mixed content warnings

Whitelist Configuration

Strict Matching Required

Redirect URIs must be exactly whitelisted in your environment configuration. No wildcards or partial matches allowed.
// ✅ Correct - exact match
redirect_uri: 'https://app.example.com/oauth/callback'

// ❌ Wrong - subdomain mismatch
redirect_uri: 'https://staging.example.com/oauth/callback'

// ❌ Wrong - path mismatch
redirect_uri: 'https://app.example.com/oauth/different-callback'

// ❌ Wrong - protocol mismatch
redirect_uri: 'http://app.example.com/oauth/callback'

// ❌ Wrong - trailing slash mismatch
redirect_uri: 'https://app.example.com/oauth/callback/'
All components must match exactly: protocol, domain, path, and port (if specified).

CORS and Redirect URIs

When your frontend application makes OAuth API calls, CORS (Cross-Origin Resource Sharing) settings must align with your redirect URI configuration.
Origin Relationship: The redirect URI domain should typically match the origin making OAuth API requests to prevent cross-origin issues.

Security Requirements

Never Use Wildcards

❌ https://*.example.com/callback
❌ https://example.com/*
✅ https://app.example.com/callback
Wildcards create security vulnerabilities allowing malicious redirects.

Use HTTPS in Production

❌ http://app.example.com/callback
✅ https://app.example.com/callback
HTTP exposes authorization codes to man-in-the-middle attacks.

Validate on Client

const ALLOWED_URIS = [
  'https://app.example.com/oauth/callback'
];

function validateRedirectURI(uri: string): boolean {
  return ALLOWED_URIS.includes(uri);
}
Client-side validation prevents configuration errors before API calls.

Environment-Specific URIs

const redirectUri = {
  development: 'http://localhost:3000/callback',
  staging: 'https://staging.example.com/callback',
  production: 'https://app.example.com/callback'
}[process.env.NODE_ENV];
Use different URIs for each environment to maintain security.

Common Redirect URI Pitfalls

Problem: Redirect URI with/without trailing slash doesn’t match whitelist
Whitelisted: https://app.example.com/callback
Sent:        https://app.example.com/callback/
Result:      ❌ Mismatch
Solution: Ensure exact match including trailing slash presence/absence.
Problem: HTTP used when HTTPS is whitelisted (or vice versa)
Whitelisted: https://app.example.com/callback
Sent:        http://app.example.com/callback
Result:      ❌ Mismatch
Solution: Match protocol exactly; always use HTTPS in production.
Problem: Explicit port in URI when whitelist doesn’t include it
Whitelisted: https://app.example.com/callback
Sent:        https://app.example.com:443/callback
Result:      ❌ Possible mismatch
Solution: Omit default ports (80 for HTTP, 443 for HTTPS) unless explicitly required.
Problem: Mobile app uses custom scheme without prior approval
Sent:   myapp://oauth/callback
Result: ❌ Not whitelisted
Solution: Contact Technical Account Manager to whitelist custom schemes before implementation.
Problem: Browser blocks OAuth API request due to CORS policy
Error: Access to fetch at 'https://api.baanx.com/v1/authorize/initiate'
from origin 'https://myapp.example.com' has been blocked by CORS policy
Solution:
  1. Verify your frontend origin is whitelisted for CORS
  2. Check redirect URI matches whitelisted configuration
  3. Contact Technical Account Manager to update CORS settings

Client-Side Validation Implementation

const ALLOWED_REDIRECT_URIS = [
  'https://app.example.com/oauth/callback'
];

function validateRedirectURI(uri: string): boolean {
  return ALLOWED_REDIRECT_URIS.includes(uri);
}

function initiateOAuth(redirectUri: string) {
  if (!validateRedirectURI(redirectUri)) {
    throw new Error('Redirect URI not whitelisted');
  }

  // Continue with OAuth
}

Configuration Checklist

1

Determine Redirect URI Scheme

Choose appropriate URI based on platform:
  • Web production: https://yourdomain.com/oauth/callback
  • Web development: http://localhost:3000/oauth/callback
  • Mobile app: yourapp://oauth/callback (requires technical account manager approval)
2

Request Whitelisting

Contact your Technical Account Manager with:
  • Redirect URI(s) for each environment
  • Custom URL schemes (if mobile - requires approval)
  • CORS origins (if different from redirect URI domain)
3

Implement Callback Handler

Create endpoint to receive authorization code:
  • Extract code and state query parameters
  • Validate state matches original value
  • Exchange code for tokens via /token endpoint
4

Test Complete Flow

Verify end-to-end:
  • Initiate OAuth with your redirect URI
  • Complete authentication
  • Callback receives authorization code
  • Code successfully exchanges for tokens

Token Storage Security

How you store tokens is critical for both protecting user data and maintaining the trust users place in your application when they grant authorization. Secure storage prevents unauthorized access to user accounts through stolen tokens.
Dual Responsibility: Token security protects both your application’s integrity and the user’s trust. A compromised token allows attackers to act as the user within the scope of granted permissions.

Storage Methods by Platform

Best: HTTP-Only Cookies
// Server-side: Set secure cookie
res.cookie('access_token', token, {
  httpOnly: true,      // Not accessible via JavaScript
  secure: true,        // HTTPS only
  sameSite: 'strict',  // CSRF protection
  maxAge: 6 * 60 * 60 * 1000  // 6 hours
});
Alternative: Memory Storage
// For SPAs, store in memory (not localStorage)
class TokenStore {
  private token: string | null = null;

  set(token: string) {
    this.token = token;
  }

  get(): string | null {
    return this.token;
  }

  clear() {
    this.token = null;
  }
}
⚠️ Avoid localStorage
// ❌ Vulnerable to XSS attacks
localStorage.setItem('access_token', token);

Common Vulnerabilities

Attack: Attacker intercepts authorization code during redirect.Prevention:
  • Use PKCE (mandatory)
  • Short authorization code lifetime (10 minutes)
  • HTTPS only in production
  • Validate redirect URI strictly
Example:
// ✅ Protected by PKCE
// Even if code is intercepted, attacker doesn't have code_verifier
const tokens = await exchangeCode({
  code: interceptedCode,
  code_verifier: storedVerifier  // Attacker doesn't have this
});
Attack: Attacker tricks user into initiating OAuth with attacker’s code.Prevention:
  • Use state parameter (mandatory)
  • Validate state on callback
  • Cryptographically random state
Example:
// ✅ Protected by state validation
if (returnedState !== storedState) {
  throw new Error('CSRF attack detected');
}
Attack: Tokens exposed in application or server logs.Prevention:
  • Never log tokens
  • Redact Authorization headers
  • Use log sanitization
Example:
// ✅ Sanitize logs
function sanitizeHeaders(headers: Headers) {
  const sanitized = { ...headers };
  if (sanitized.authorization) {
    sanitized.authorization = 'Bearer [REDACTED]';
  }
  return sanitized;
}

logger.info('API call', { headers: sanitizeHeaders(headers) });
Attack: Malicious script steals tokens from localStorage.Prevention:
  • Don’t store tokens in localStorage
  • Use HTTP-only cookies
  • Implement Content Security Policy
  • Sanitize user inputs
Example:
// ❌ Vulnerable
localStorage.setItem('token', accessToken);

// ✅ Secure
res.cookie('token', accessToken, { httpOnly: true });
Attack: Reuse intercepted authorization code or token.Prevention:
  • Authorization codes are single-use
  • Short token lifetimes
  • Implement token revocation
  • Use HTTPS
Server-side check:
def exchange_code(code: str):
    if redis.get(f'used_code:{code}'):
        raise Exception('Code already used')

    # Mark as used
    redis.setex(f'used_code:{code}', 600, '1')

    # Exchange for tokens
Attack: Fake login page steals user credentials.Prevention (Hosted UI):
  • Use hosted UI for authentication
  • Educate users to verify URL
  • Implement domain verification
Prevention (API Mode):
  • Use certificate pinning
  • Implement biometric authentication
  • Add additional verification steps

Security Checklist

Pre-Production

PKCE Implementation

  • Code verifier is 43-128 characters
  • SHA256 used for code challenge
  • code_challenge_method is S256
  • Verifier stored securely
  • Verifier validated on exchange

State Parameter

  • State is cryptographically random
  • State is at least 16 characters
  • State stored in secure session
  • State validated on callback
  • State is single-use

Secret Management

  • Secrets in environment variables
  • Secrets never in client code
  • Secrets never in version control
  • Secrets not logged
  • Different keys per environment

Redirect URI

  • All URIs whitelisted
  • HTTPS used in production
  • Exact matching implemented
  • No wildcards used
  • Client-side validation added

Token Storage

  • Platform secure storage used
  • Not in localStorage (web)
  • HTTP-only cookies (web)
  • Keychain (iOS)
  • EncryptedSharedPreferences (Android)

Error Handling

  • Tokens not in error messages
  • Secrets not exposed on error
  • Sanitized logging implemented
  • User-friendly error messages
  • Security events logged

Runtime Security

  • HTTPS enforced in production
  • Certificate validation enabled
  • TLS 1.2+ required
  • Token refresh before expiry
  • Expired refresh token handled
  • Token revocation implemented
  • Logout clears all tokens
  • Session timeout enforced
  • Rate limiting implemented
  • Security headers set

Monitoring

  • Failed authentication monitored
  • Token refresh failures logged
  • Suspicious activity detected
  • Security events alerted
  • Access patterns analyzed

Security Headers

Implement these HTTP security headers:
import helmet from 'helmet';

app.use(helmet());

app.use((req, res, next) => {
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('Content-Security-Policy', "default-src 'self'");
  res.setHeader('Referrer-Policy', 'no-referrer');
  next();
});

Incident Response

If you suspect a security breach:
1

Immediate Actions

  • Revoke all affected tokens immediately
  • Rotate compromised secrets
  • Force password reset for affected users
  • Disable compromised client keys
2

Investigation

  • Review access logs
  • Identify scope of breach
  • Document timeline
  • Preserve evidence
3

Notification

  • Notify affected users
  • Report to security team
  • Comply with regulations (GDPR, etc.)
  • Update security documentation
4

Prevention

  • Implement additional controls
  • Update security practices
  • Conduct security review
  • Train development team

Additional Resources

Next Steps