Skip to main content

OAuth Security Best Practices: Protecting Your Application

Published: March 9, 2026 Updated: May 8, 2026 Larry Qu 22 min read

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 2.0 is not an authentication protocol — it is an authorization framework, which means the security burden falls on the implementer rather than being baked into the spec. The specification deliberately leaves many decisions open, and attackers have learned to exploit those gaps. Before writing any code, it pays to enumerate the concrete attacks your implementation will face, because each defensive measure in this article maps to at least one specific vulnerability. The dictionary below consolidates the six most dangerous attack classes in a single, easy-to-review structure.

Read each entry carefully and note the pattern: every attack names an impact and a mitigation. Authorization code interception lets an attacker exchange a stolen code for tokens, and the standard mitigation is PKCE. CSRF attacks abuse the browser’s implicit trust in cookies to bind an attacker’s account to the victim’s login, which the state parameter prevents. Redirect URI mismatch exploits the fact that the authorization server sends the code to whatever callback URI the client registered, so an overly loose redirect URI becomes a token-theft conduit.

The remaining three are about token and scope hygiene. Token leakage covers the depressingly common practice of storing access tokens in localStorage, logs, or URL query strings, where any XSS or log scanner can harvest them. Refresh token rotation bypass occurs when a leaked refresh token remains valid indefinitely, granting the attacker persistent access even after the user logs out. Scope escalation happens when an attacker requests more permissions than the user actually granted. If you keep this dictionary in mind while reviewing each section of your implementation, every security control discussed below will have a clear purpose.

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 introduced to protect public clients — native mobile apps and single-page applications that cannot keep a client secret confidential. But the OAuth security best current practice now recommends it for every flow, including confidential clients, because it neutralizes authorization code interception even when the code travels over an unexpected path. The idea is elegant: the client generates a random code_verifier, sends a derived code_challenge during authorization, and then proves possession of the verifier when exchanging the code for tokens. A stolen code is worthless without the verifier, so interception attacks fail at the final step.

The JavaScript class below is a complete PKCE implementation you can adapt directly. generateCodeVerifier draws from a cryptographically secure random source and maps bytes into the URL-safe alphabet, producing a verifier that is both unpredictable and transport-safe. generateCodeChallenge applies SHA-256 and base64url encoding to turn the verifier into the challenge sent to the authorization server — note that the plaintext verifier is never sent during this first leg, which is what keeps the scheme secure.

The initiateOAuthFlow method ties the whole flow together. It generates the verifier and challenge, produces a random state value for CSRF protection, stores both in sessionStorage (a deliberate choice over localStorage because it clears when the tab closes), and redirects the browser to the authorization server. The exchangeCodeForTokens method later retrieves the stored verifier and submits it with the authorization code, then immediately deletes it — a single-use discipline that prevents replay. The one thing this class does not show is the server-side verification, but remember: the authorization server computes the challenge from the verifier you send and compares it to the one it received; if they do not match, the exchange fails.

// 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

Where you store tokens determines how badly a single vulnerability can hurt you, and this is the decision that separates amateur OAuth deployments from production systems. The golden rule is simple: never place tokens where JavaScript can read them, because any cross-site scripting (XSS) flaw then becomes a complete account takeover. That rules out localStorage and sessionStorage for tokens, and it means the access token should live behind an httpOnly cookie or in server-side session state that the browser never directly touches.

The JavaScript below demonstrates the server-side pattern. Instead of handing tokens to the client, storeTokens persists the full token set in Redis under a randomly generated session ID with an expiration matching the access token’s lifetime, and the client receives only that opaque identifier. This approach has a major advantage: tokens are revocable. If the server deletes the Redis key, the session dies instantly, regardless of what the client believes. You cannot do that with a self-contained JWT stored in the browser.

setAuthCookies shows how the session ID reaches the browser. The access-token cookie is short-lived (15 minutes), the refresh-token cookie lives seven days, and both are marked httpOnly, secure, and sameSite: strict. The sameSite attribute is a quiet but critical CSRF defense, and secure enforces HTTPS-only transmission. Finally, invalidateTokens implements logout and session termination by deleting both the session and refresh keys. When a user signs out, you want every trace of their session gone from the server, not merely cleared from the browser.

// 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

Refresh tokens are the most valuable credential in an OAuth system because they can mint new access tokens indefinitely. If one leaks and stays valid forever, the attacker owns the account as long as they remember to refresh. Refresh token rotation is the countermeasure: every time a refresh token is used, it is invalidated and a brand-new one is issued, so a stolen token can be used exactly once — and the first reuse after theft can be detected as an anomaly. The token family concept extends this by tagging every descendant of an original token, making it possible to revoke an entire lineage.

The Python implementation below embodies the rotation discipline. rotate_refresh_token first looks up the presented token in the database, and here is the critical subtlety: if the token is not found or is already revoked, the code does not simply return an error. It calls revoke_all_user_tokens, because a missing token strongly suggests an attacker is replaying a rotated token and the legitimate session must be terminated defensively. The code then checks expiry, issues a fresh JWT access token and a new random refresh token, stores the new token with a family reference, and marks the old one revoked.

This design balances security against usability. Because every rotation writes two database records (insert new, revoke old), you must weigh the operational cost against the security gain — but modern guidance is unambiguous that rotation is worth it. Note also that access tokens are short-lived JWTs with a one-hour expires_in, keeping the window of exposure small even if one leaks. And when theft is detected, revoke_all_user_tokens can kill every session for the user at once, which is exactly the kind of kill switch you want available when monitoring flags a compromise.

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

The redirect URI is the authorization server’s only channel for delivering the authorization code back to the client, which makes it the single most attacked parameter in the entire OAuth ecosystem. If an attacker can register or guess a redirect URI that you also accept, they can trick the server into sending the code to their domain. The defense is strict, exact-match validation on the server: every redirect URI presented during authorization must precisely match a registered value, character for character. Fuzzy matching, prefix matching, or allowing query-parameter variations is how real-world breaches happen.

The RedirectUriValidator class below implements this discipline. The first check is exact membership in the allowed list — no substring matching, no normalization tricks. In production it then enforces HTTPS, rejecting cleartext callbacks that would expose the code in transit. It also rejects fragments (#) and wildcards (*), two common attack patterns where a trailing fragment or a glob-like pattern silently widens what the server will accept.

The most valuable part is the host check at the end: it extracts the host from both the provided URI and every allowed URI and requires an exact host match, closing the class of open-redirect bugs where a legitimate path prefix is abused. The generateExactMatch helper then shows the correct way to support multiple redirect URIs — by registering each full URI and looking up the exact one associated with the requesting client_id, rather than reconstructing it from components. The usage example wires the validator into an Express callback route, returning a 400 with a structured error description when validation fails, so the failure mode is explicit and debuggable.

// 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

The state parameter is OAuth’s built-in defense against CSRF (cross-site request forgery) in the login flow. Without it, an attacker can start an OAuth login for their own account, send the victim’s browser to the authorization server, and then use the returned code to bind the attacker’s account to the victim’s login — in other words, the victim silently ends up logged in as the attacker. The state parameter defeats this because it is a random value generated at the start of the flow, bound to the user’s session, and validated on the callback. If the value coming back does not match what the session stored, the flow is aborted.

The CSRFProtection class below shows a rigorous implementation. generateState produces 32 bytes of cryptographically random data and — this is the key detail — stores it in a signed cookie rather than a readable JavaScript-accessible location. Signing the cookie prevents tampering, and httpOnly keeps it out of JavaScript’s reach entirely, so even an XSS payload cannot read or forge the state value.

validateState performs the comparison with timingSafeEqual, a constant-time comparison that prevents timing attacks from leaking the stored value bit by bit. The code also documents that state should be single-use: after a successful validation the stored value is deleted, so replaying the same callback cannot succeed a second time. The middleware wiring at the bottom sets the cookie with a 10-minute lifetime and a sameSite: lax attribute, then validates state on the callback route. This is the pattern to replicate in any framework — random generation, signed httpOnly storage, constant-time comparison, and single-use semantics.

// 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

Scopes are the authorization tokens of permissions: a client requests a set of scopes, and if granted, the tokens it receives carry exactly those rights. The failure mode that matters here is escalation — a client asking for more than it needs, or a client being granted scopes the user never authorized. The remedy is two-sided validation. First, the authorization server must only issue scopes the client is registered to use. Second, on the callback side, your application must verify that the scopes returned by the provider are actually within the user’s entitlements, because the provider’s guarantees do not automatically translate into your domain’s authorization model.

The ScopeValidator class below implements the second half of that contract. Its validate method checks each requested scope twice: once against the client’s allowed scope list, and once against the user’s actual permissions loaded from your database. Both checks must pass, and any failure returns a precise 403 with the offending scope named — so a caller cannot silently drop an unauthorized scope and continue.

The class also encodes a scope hierarchy, where requesting user:email implies the narrower read:user:email and read:user scopes. This is a clean way to support permission refinement without bespoke logic scattered through the codebase. filterGrantedScopes enforces least privilege in the other direction: given what the user requested and what is actually available, it returns only the intersection, so you never grant more than was asked. Combined with the callback route that loads userScopes from the database before exchanging the code, this gives you a defensible, auditable scope boundary.

// 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

Even a flawless OAuth implementation can be undermined by browser-level weaknesses, which is why security headers and CORS policies are part of the defense-in-depth posture. Headers like Content-Security-Policy tell the browser what resources a page may load, which directly shrinks the blast radius of XSS: if script-src refuses to load attacker-controlled domains, many token-stealing payloads simply stop working. X-Frame-Options and Permissions-Policy respectively block clickjacking and disable unused browser features, and Referrer-Policy prevents tokens or codes from leaking through the Referer header when users navigate away.

The security headers object below shows a pragmatic production configuration. The CSP carefully whitelists only the origins involved in the OAuth flow — accounts.google.com for script and frame sources, oauth2.googleapis.com and github.com for connections — while keeping default-src 'self' as the baseline. The 'unsafe-inline' allowances for scripts and styles are a deliberate trade-off you should revisit: they keep the example concise, but a strict CSP without inline execution is safer and should be the goal for a hardened deployment.

CORS is a separate but related concern. The configuration whitelists specific origins, allows credentials, and restricts methods and headers. The key decision is refusing unknown origins: credentials: true makes cross-origin requests carry cookies, and if you combine credentials with an open origin: *, any website can invoke your OAuth endpoints with the victim’s session. By whitelisting and calling callback(new Error(...)) for everything else, this config turns CORS into a positive security control rather than a default-permissive convenience.

// 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 controls reduce the likelihood of an attack, but monitoring is what detects the attack that slipped through — and in authentication systems, detection time is the difference between a contained incident and a full breach. Every authentication attempt is a signal: repeated failures from one IP, token reuse across sessions, and sudden geolocation changes are all early indicators of credential theft. If you log nothing, you have no way to see them, and no evidence trail for an incident response.

The OAuthSecurityMonitor class below structures that logging and alerting. logAuthAttempt records a rich event — provider, success flag, user ID, IP address, user agent, and a computed risk score — which is the kind of structured log you can query, alert on, and feed into a SIEM. The fields make the intent explicit: this is a compliance-friendly audit record, not a debugging trace.

checkForAnomalies then turns those logs into alerts with distinct severities. More than ten failed attempts from a single IP in an hour triggers a high-severity rate-limit alert. Detecting a reused token is treated as critical, because it almost certainly means a session has been compromised. And a risk score above 0.8 — for example, a login from a new country followed by a high-value action — fires a medium-severity alert for human review. The getFailedAttempts stub is the integration point where you would query your actual log store. Notice the design principle throughout: every alert has a type and severity, making it possible to route to the right responder automatically.

// 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

When the code is written and the flows work, the remaining question is whether the deployment is actually production-grade. The checklist below converts every principle from this article into verifiable items — a tool for code review, pre-launch audit, and continuous verification. Checklists like this are valuable precisely because OAuth security is a long tail of small details, and small details are what auditors and attackers both check first.

Read it as four layers. The authorization layer verifies the flow-level defenses: PKCE on every flow, strict redirect URI validation, state-based CSRF protection, scope validation, token expiration, and HTTPS end to end. The token management layer checks storage and lifecycle: httpOnly Secure cookies, refresh token rotation, server-side storage, logout invalidation, and revocation. If any of these items is missing, tokens are the most likely attack surface an assessor will probe.

The monitoring and infrastructure layers round out the deployment. Logging every authentication attempt, alerting on anomalies, tracking token theft, and measuring failed-auth rates give you operational visibility. Security headers, CORS configuration, rate limiting, and a web application firewall harden the edge. Finally, the code review layer is a reminder that configuration alone is not enough: a review pass, attack-vector testing, and verification of token storage and redirect validation should be scheduled, not one-off. Treat the checklist as living documentation — update it as your flows evolve and as new attacks are documented.

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

👍 Was this article helpful?