Skip to main content
โšก Calmops

OAuth Security Best Practices: Protecting Your Application

Introduction

OAuth 2.0 is the foundation of modern authentication, but improper implementation can lead to serious security vulnerabilities. This guide covers essential security practices for implementing OAuth, protecting against common attacks, and ensuring your authentication system is production-ready.

Common OAuth Vulnerabilities

Attack Vectors

OAUTH_ATTACKS = {
    "authorization_code_interception": {
        "description": "Attacker intercepts authorization code and exchanges it for tokens",
        "impact": "Full account takeover",
        "mitigation": "Use PKCE for all flows"
    },
    "csrf_attack": {
        "description": "Attacker initiates OAuth flow in victim's browser to link attacker's account",
        "impact": "Account takeover",
        "mitigation": "Validate state parameter"
    },
    "redirect_uri_mismatch": {
        "description": "Attacker uses legitimate redirect_uri to steal authorization code",
        "impact": "Token theft",
        "mitigation": "Strict redirect_uri validation"
    },
    "token_leakage": {
        "description": "Tokens exposed through localStorage, logs, or URLs",
        "impact": "Session hijacking",
        "mitigation": "Use httpOnly cookies, secure storage"
    },
    "refresh_token_rotation_bypass": {
        "description": "Attacker obtains refresh token and uses it indefinitely",
        "impact": "Persistent access",
        "mitigation": "Rotate refresh tokens"
    },
    "scope_escalation": {
        "description": "Attacker requests additional scopes beyond authorization",
        "impact": "Unauthorized access",
        "mitigation": "Validate requested scopes"
    }
}

PKCE Implementation

Why PKCE Matters

Proof Key for Code Exchange (PKCE) was originally designed for mobile apps but is now recommended for all OAuth flows:

// Complete PKCE Implementation
class PKCEHandler {
  constructor() {
    this.ASCII_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  }

  // Generate cryptographically random code verifier
  generateCodeVerifier() {
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    
    let result = '';
    for (let i = 0; i < 32; i++) {
      result += this.ASCII_CHARS[array[i] % this.ASCII_CHARS.length];
    }
    
    return result;
  }

  // Generate code challenge from verifier
  async generateCodeChallenge(codeVerifier) {
    // SHA-256 hash
    const encoder = new TextEncoder();
    const data = encoder.encode(codeVerifier);
    const hash = await crypto.subtle.digest('SHA-256', data);
    
    // Base64URL encode
    return btoa(String.fromCharCode(...new Uint8Array(hash)))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  // Verify code challenge
  async verifyCodeChallenge(codeVerifier, codeChallenge) {
    const computed = await this.generateCodeChallenge(codeVerifier);
    return computed === codeChallenge;
  }

  // Full OAuth flow with PKCE
  async initiateOAuthFlow(authorizationUrl, clientId, redirectUri, scopes) {
    // Step 1: Generate PKCE parameters
    const codeVerifier = this.generateCodeVerifier();
    const codeChallenge = await this.generateCodeChallenge(codeVerifier);
    const state = this.generateRandomState();

    // Step 2: Store verifier securely (session or server-side)
    sessionStorage.setItem('pkce_verifier', codeVerifier);
    sessionStorage.setItem('oauth_state', state);

    // Step 3: Build authorization URL with PKCE
    const params = new URLSearchParams({
      client_id: clientId,
      redirect_uri: redirectUri,
      response_type: 'code',
      scope: scopes.join(' '),
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
      state: state
    });

    // Step 4: Redirect to authorization server
    window.location.href = `${authorizationUrl}?${params}`;
  }

  async exchangeCodeForTokens(code, tokenUrl, clientId, redirectUri) {
    // Retrieve stored verifier
    const codeVerifier = sessionStorage.getItem('pkce_verifier');
    
    if (!codeVerifier) {
      throw new Error('Code verifier not found');
    }

    // Exchange code for tokens
    const response = await fetch(tokenUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        client_id: clientId,
        redirect_uri: redirectUri,
        code_verifier: codeVerifier
      })
    });

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

    return response.json();
  }

  generateRandomState() {
    const array = new Uint8Array(16);
    crypto.getRandomValues(array);
    return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
  }
}

Token Security

Secure Token Storage

// NEVER store tokens in localStorage
// Use httpOnly, Secure cookies instead

// Server-side token handling
class SecureTokenHandler {
  constructor(redisClient) {
    this.redis = redisClient;
  }

  // Store tokens with server-side session ID
  async storeTokens(userId, accessToken, refreshToken, expiresIn) {
    const sessionId = crypto.randomUUID();
    
    // Store in Redis with expiration
    const tokenData = {
      accessToken,
      refreshToken,
      userId,
      createdAt: Date.now(),
      expiresAt: Date.now() + expiresIn * 1000
    };

    await this.redis.setex(
      `session:${sessionId}`,
      expiresIn,
      JSON.stringify(tokenData)
    );

    return sessionId;
  }

  // Set HTTP-only cookies
  setAuthCookies(res, sessionId, refreshTokenId) {
    // Access token cookie (short-lived)
    res.cookie('access_token', sessionId, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000, // 15 minutes
      path: '/'
    });

    // Refresh token cookie (longer-lived)
    res.cookie('refresh_token', refreshTokenId, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
      path: '/'
    });
  }

  // Get tokens from session
  async getTokens(sessionId) {
    const data = await this.redis.get(`session:${sessionId}`);
    return data ? JSON.parse(data) : null;
  }

  // Invalidate tokens (logout)
  async invalidateTokens(sessionId, refreshTokenId) {
    await this.redis.del(`session:${sessionId}`);
    await this.redis.del(`refresh:${refreshTokenId}`);
  }
}

Token Rotation

import secrets
from datetime import datetime, timedelta

class TokenRotation:
    """Handle refresh token rotation."""
    
    def __init__(self, db):
        self.db = db
    
    async def rotate_refresh_token(self, user_id: str, old_token: str) -> dict:
        """Rotate refresh token - issue new one, invalidate old."""
        
        # Verify old token exists
        stored_token = await self.db.refresh_tokens.find_one({
            "token": old_token,
            "user_id": user_id,
            "revoked": False
        })
        
        if not stored_token:
            # Token might be stolen - revoke all user tokens
            await self.revoke_all_user_tokens(user_id)
            raise ValueError("Invalid refresh token - possible theft detected")
        
        # Check if token is expired
        if datetime.utcnow() > stored_token["expires_at"]:
            raise ValueError("Refresh token expired")
        
        # Generate new tokens
        new_access_token = self.generate_jwt(user_id, expires_in=3600)
        new_refresh_token = secrets.token_urlsafe(32)
        
        # Store new refresh token
        await self.db.refresh_tokens.insert_one({
            "user_id": user_id,
            "token": new_refresh_token,
            "created_at": datetime.utcnow(),
            "expires_at": datetime.utcnow() + timedelta(days=30),
            "revoked": False,
            "family": stored_token.get("family")  # Track token family
        })
        
        # Revoke old token
        await self.db.refresh_tokens.update_one(
            {"_id": stored_token["_id"]},
            {"$set": {"revoked": True, "revoked_at": datetime.utcnow()}}
        )
        
        return {
            "access_token": new_access_token,
            "refresh_token": new_refresh_token,
            "expires_in": 3600
        }
    
    async def revoke_all_user_tokens(self, user_id: str):
        """Revoke all tokens for user (when theft detected)."""
        await self.db.refresh_tokens.update_many(
            {"user_id": user_id, "revoked": False},
            {
                "$set": {
                    "revoked": True,
                    "revoked_reason": "security_compromise",
                    "revoked_at": datetime.utcnow()
                }
            }
        )

Redirect URI Validation

// Server-side redirect URI validation
class RedirectUriValidator {
  constructor(allowedUris) {
    this.allowedUris = allowedUris;
  }

  validate(providedUri) {
    const errors = [];

    // Must be registered
    if (!this.allowedUris.includes(providedUri)) {
      errors.push('Redirect URI not registered');
      return { valid: false, errors };
    }

    // Must use HTTPS in production
    if (process.env.NODE_ENV === 'production') {
      const parsed = new URL(providedUri);
      if (parsed.protocol !== 'https:') {
        errors.push('Redirect URI must use HTTPS');
      }
    }

    // No fragments allowed
    if (providedUri.includes('#')) {
      errors.push('Redirect URI must not contain fragments');
    }

    // No wildcards
    if (providedUri.includes('*')) {
      errors.push('Redirect URI must not contain wildcards');
    }

    // Prevent open redirect
    const allowedHosts = this.allowedUris.map(uri => new URL(uri).host);
    const providedHost = new URL(providedUri).host;
    if (!allowedHosts.includes(providedHost)) {
      errors.push('Redirect URI host not allowed');
    }

    return { valid: errors.length === 0, errors };
  }

  // Generate redirect URI for validation
  generateExactMatch(clientId, redirectUri) {
    const config = this.allowedUris.find(uri => {
      const parsed = new URL(uri);
      return parsed.searchParams.get('client_id') === clientId;
    });

    if (config) {
      const baseUri = config.split('?')[0];
      return `${baseUri}?client_id=${clientId}`;
    }

    return redirectUri;
  }
}

// Usage
const validator = new RedirectUriValidator([
  'https://app.example.com/oauth/callback',
  'https://app.example.com/callback',
  'http://localhost:3000/callback'
]);

app.get('/auth/callback', (req, res) => {
  const { redirect_uri } = req.query;
  
  const validation = validator.validate(redirect_uri);
  
  if (!validation.valid) {
    return res.status(400).json({ 
      error: 'invalid_request', 
      error_description: validation.errors.join(', ') 
    });
  }
  
  // Continue with OAuth flow...
});

State Parameter Implementation

// CSRF protection with state parameter
class CSRFProtection {
  generateState() {
    // Cryptographically random state
    const state = crypto.randomBytes(32).toString('hex');
    
    // Store in signed cookie (not accessible to JavaScript)
    // This prevents tampering
    return state;
  }

  validateState(req, providedState) {
    // Get stored state from cookie
    const storedState = req.signedCookies?.oauth_state;
    
    if (!storedState) {
      throw new Error('No state cookie found');
    }

    // Constant-time comparison to prevent timing attacks
    if (!crypto.timingSafeEqual(
      Buffer.from(storedState),
      Buffer.from(providedState)
    )) {
      throw new Error('State mismatch - possible CSRF attack');
    }

    // State is single-use - generate new one after validation
    // In practice, delete the stored state
    return true;
  }
}

// Express middleware
const csrfProtection = new CSRFProtection();

app.get('/auth/google', (req, res) => {
  const state = csrfProtection.generateState();
  
  // Sign state into cookie
  res.cookie('oauth_state', state, {
    signed: true,
    httpOnly: true,
    secure: true,
    maxAge: 10 * 60 * 1000, // 10 minutes
    sameSite: 'lax'
  });
  
  // Redirect to Google...
});

app.get('/auth/callback', (req, res) => {
  try {
    csrfProtection.validateState(req, req.query.state);
    // Continue with token exchange
  } catch (error) {
    return res.status(400).send('Authentication failed');
  }
});

Scope Validation

// Validate requested scopes
class ScopeValidator {
  constructor(allowedScopes) {
    this.allowedScopes = allowedScopes;
    // Define scope hierarchy
    this.scopeHierarchy = {
      'read:user': ['read:user'],
      'read:user:email': ['read:user:email', 'read:user'],
      'user:email': ['user:email', 'read:user:email', 'read:user']
    };
  }

  validate(requestedScopes, userScopes) {
    const requested = new Set(requestedScopes.split(' '));
    const allowed = new Set(userScopes);

    // Check each requested scope
    for (const scope of requested) {
      // Scope must be allowed for this client
      if (!this.allowedScopes.includes(scope)) {
        return { valid: false, error: `Scope not allowed: ${scope}` };
      }

      // User must have this scope
      if (!allowed.has(scope)) {
        return { valid: false, error: `User not authorized for: ${scope}` };
      }
    }

    return { valid: true };
  }

  // Enforce least privilege - only grant what's requested
  filterGrantedScopes(requestedScopes, availableScopes) {
    const requested = new Set(requestedScopes.split(' '));
    const available = new Set(availableScopes);

    // Only grant scopes that were both requested AND are available
    const granted = [...requested].filter(scope => available.has(scope));

    return granted.join(' ');
  }
}

// Usage
const scopeValidator = new ScopeValidator([
  'openid', 'profile', 'email',
  'read:user', 'read:user:email',
  'user:email', 'user:follow'
]);

app.get('/auth/callback', async (req, res) => {
  const { scope } = req.session;
  
  // Get user's actual permissions from database
  const userScopes = await getUserScopes(req.session.userId);
  
  const validation = scopeValidator.validate(scope, userScopes);
  
  if (!validation.valid) {
    return res.status(403).json({ error: validation.error });
  }
  
  // Exchange code for tokens...
});

Security Headers and CORS

// Security middleware
const securityHeaders = {
  // Content Security Policy
  'Content-Security-Policy': [
    "default-src 'self'",
    "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com",
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "frame-src https://accounts.google.com https://github.com",
    "connect-src 'self' https://oauth2.googleapis.com https://github.com"
  ].join('; '),

  // Other security headers
  'X-Content-Type-Options': 'nosniff',
  'X-Frame-Options': 'DENY',
  'X-XSS-Protection': '1; mode=block',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'Permissions-Policy': 'geolocation=(), microphone=(), camera=()'
};

// Apply headers
app.use((req, res, next) => {
  Object.entries(securityHeaders).forEach(([header, value]) => {
    res.setHeader(header, value);
  });
  next();
});

// CORS for OAuth endpoints
const corsOptions = {
  origin: (origin, callback) => {
    // Whitelist specific origins
    const allowedOrigins = [
      'https://app.example.com',
      'http://localhost:3000'
    ];
    
    // Allow requests without origin (mobile apps, Postman)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type', 'Authorization']
};

app.use('/oauth/', cors(corsOptions));

Monitoring and Logging

// Security monitoring for OAuth
class OAuthSecurityMonitor {
  constructor(logger, alertService) {
    this.logger = logger;
    this.alert = alertService;
  }

  async logAuthAttempt(data) {
    const logEntry = {
      timestamp: new Date().toISOString(),
      event: 'oauth_authentication',
      provider: data.provider,
      success: data.success,
      userId: data.userId,
      ipAddress: data.ipAddress,
      userAgent: data.userAgent,
      riskScore: data.riskScore || 0
    };

    this.logger.info(logEntry);

    // Alert on suspicious patterns
    await this.checkForAnomalies(data);
  }

  async checkForAnomalies(data) {
    // Multiple failed attempts from same IP
    const failedCount = await this.getFailedAttempts(data.ipAddress, '1h');
    
    if (failedCount > 10) {
      await this.alert.send({
        type: 'oauth_rate_limit_exceeded',
        severity: 'high',
        details: { ip: data.ipAddress, attempts: failedCount }
      });
    }

    // Token reuse detection
    if (data.tokenReused) {
      await this.alert.send({
        type: 'token_reuse_detected',
        severity: 'critical',
        details: { userId: data.userId }
      });
    }

    // Suspicious location
    if (data.riskScore > 0.8) {
      await this.alert.send({
        type: 'high_risk_authentication',
        severity: 'medium',
        details: { userId: data.userId, riskFactors: data.riskFactors }
      });
    }
  }

  async getFailedAttempts(ip, window) {
    // Query logs for failed attempts
    return 0; // Implementation depends on logging system
  }
}

Production Checklist

oauth_security_checklist:
  authorization:
    - [x] Implement PKCE for all flows
    - [x] Validate redirect_uri strictly
    - [x] Use state parameter (CSRF protection)
    - [x] Validate all requested scopes
    - [x] Implement token expiration
    - [x] Use HTTPS everywhere

  token_management:
    - [x] Use httpOnly, Secure cookies
    - [x] Implement refresh token rotation
    - [x] Store tokens server-side (Redis/database)
    - [x] Invalidate tokens on logout
    - [x] Implement token revocation

  monitoring:
    - [x] Log all authentication attempts
    - [x] Alert on suspicious patterns
    - [x] Monitor for token theft
    - [x] Track failed authentication rates

  infrastructure:
    - [x] Use security headers (CSP, HSTS)
    - [x] Configure CORS properly
    - [x] Implement rate limiting
    - [x] Use WAF in production

  code_review:
    - [x] Review OAuth implementation
    - [x] Test attack vectors
    - [x] Verify token storage
    - [x] Check redirect validation

Conclusion

OAuth security requires attention to multiple layers:

  1. Always use PKCE - Protects against code interception
  2. Validate everything - redirect_uri, state, scopes
  3. Store tokens securely - httpOnly cookies, server-side storage
  4. Rotate tokens - Refresh token rotation prevents abuse
  5. Monitor continuously - Detect attacks early

Regular security audits and penetration testing help identify vulnerabilities before attackers do.

Resources

Comments