CORS is the most misunderstood security feature on the modern web. Developers hit a CORS error during development, Google "fix CORS error," and end up with Access-Control-Allow-Origin: * plus Access-Control-Allow-Credentials: true shipped to production. Congratulations — you've just disabled the same-origin policy for authenticated requests, and any malicious site on the internet can read your users' API responses.
CORS isn't a security feature in the way most people think. It doesn't protect your API from being called. Anyone with curl can call your API regardless of CORS configuration. What CORS does is selectively relax the same-origin policy that prevents browsers from reading cross-origin responses. Get the relaxation wrong and you've handed attackers a credential-theft primitive.
This guide explains what CORS actually is, why misconfigurations are dangerous, and how to configure it correctly in Express, Django, and Next.js. We'll cover the specific patterns attackers exploit and how to detect CORS issues in your own services before they reach production.
Check your site right now: Free ZeriFlow scan in 60 seconds →
What CORS Actually Is
The same-origin policy (SOP) is a foundational browser rule: scripts loaded from origin-A cannot read responses from origin-B. Without SOP, every site you visit could read your Gmail, your bank balance, and your Slack DMs in the background.
CORS — Cross-Origin Resource Sharing — is a controlled exception to SOP. The server explicitly tells the browser "yes, scripts from this other origin can read my responses" via headers like Access-Control-Allow-Origin, Access-Control-Allow-Credentials, and Access-Control-Allow-Methods.
Three things to understand:
- 1CORS is enforced by the browser, not the server. The server merely advertises what's allowed. A non-browser client (curl, Postman, a mobile app) ignores CORS entirely.
- 2CORS only matters for cross-origin reads. Same-origin requests, image loads, form submissions, and
<script src>includes don't go through CORS. - 3Authenticated cross-origin requests are special. They require
Access-Control-Allow-Credentials: trueAND a specific allowed origin (no wildcards). This is the rule attackers most often see violated.
Why CORS Misconfiguration Is Dangerous
A misconfigured CORS policy turns the same-origin policy into a suggestion. Three concrete attack patterns:
Pattern 1: Wildcard Origin with Credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueThe spec actually forbids this combination — browsers will reject the response. But many developers don't know that, and many frameworks let you ship it. More dangerously, developers often "fix" the rejection by reflecting the request's Origin header back unchanged:
Access-Control-Allow-Origin: https://attacker.example
Access-Control-Allow-Credentials: trueNow any site on the internet can issue authenticated requests to your API and read the responses. The user's session cookie is sent automatically, the attacker's site reads the response, and the attacker exfiltrates whatever the API returns.
Pattern 2: Reflecting Origin Without Validation
The classic regex bug:
// VULNERABLE
const allowedOrigin = /^https:\/\/.*\.example\.com$/;
if (allowedOrigin.test(req.headers.origin)) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}The regex matches https://attacker.example.com (which the attacker controls) because of the wildcard subdomain. It also matches https://evilexample.com because the dot isn't escaped. Both are common bugs.
Pattern 3: null Origin Allowed
Some frameworks allow Origin: null, which is sent by sandboxed iframes, file:// URLs, and certain redirects. An attacker can craft a sandboxed iframe that issues authenticated requests with a null origin and reads the response.
Pattern 4: Trusting Subdomains You Don't Control
If *.example.com is on your allowlist and you've ever let a third party host content on a subdomain (status pages, marketing landing pages, ticket portals), that third party can issue cross-origin requests to your API on behalf of your users.
How to Configure CORS Correctly
The general rule: be explicit, be minimal, and never trust the request's Origin header without validation.
Express (Node.js)
Use the cors middleware, configured with an explicit allowlist:
const cors = require('cors');
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
app.use(cors({
origin: (origin, callback) => {
// Same-origin requests have no Origin header — allow them.
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
}));Key points: explicit origin list, no regex, credentials only when actually needed, narrow methods and headers.
Django
Use django-cors-headers:
# settings.py
INSTALLED_APPS = [
# ...
"corsheaders",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
CORS_ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_METHODS = ["GET", "POST", "PUT", "DELETE"]
CORS_ALLOWED_HEADERS = ["content-type", "authorization"]
CORS_PREFLIGHT_MAX_AGE = 600
# DO NOT use CORS_ALLOWED_ORIGIN_REGEXES unless you really know what you're doing.
# DO NOT set CORS_ALLOW_ALL_ORIGINS = True with credentials.Next.js (App Router, Route Handlers)
Set headers manually on each route handler or use middleware:
// app/api/data/route.ts
import { NextResponse } from 'next/server';
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
export async function GET(request: Request) {
const origin = request.headers.get('origin') ?? '';
const allowed = allowedOrigins.includes(origin);
const response = NextResponse.json({ data: 'value' });
if (allowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
response.headers.set('Vary', 'Origin');
}
return response;
}
export async function OPTIONS(request: Request) {
const origin = request.headers.get('origin') ?? '';
const allowed = allowedOrigins.includes(origin);
const response = new NextResponse(null, { status: 204 });
if (allowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
response.headers.set('Access-Control-Max-Age', '600');
response.headers.set('Vary', 'Origin');
}
return response;
}Critical detail: Vary: Origin ensures CDNs cache different responses for different origins. Without it, the wrong cached response can leak across origins.
CORS and Authentication: The Cookie vs Bearer Token Trade-off
If your API uses cookie-based authentication, CORS misconfigurations are catastrophic — credentials are sent automatically. If your API uses bearer tokens (Authorization header), the attack surface is smaller because tokens aren't sent automatically across origins.
For new APIs, prefer bearer tokens with short expiry plus refresh tokens stored in HttpOnly Secure SameSite=Strict cookies on a separate refresh endpoint. This minimises the CORS blast radius.
If you must use cookies, set SameSite=Strict (or at least Lax) on session cookies. SameSite=None plus a misconfigured CORS is the worst-case scenario.
How to Detect CORS Misconfigurations
Three layers of detection:
Manual Testing
# Send a request with a clearly malicious Origin
curl -I -H "Origin: https://attacker.example" https://api.example.com/users/me
# Look at the response headers. If you see:
# Access-Control-Allow-Origin: https://attacker.example
# Access-Control-Allow-Credentials: true
# ...you have a problem.Try variations: Origin: null, Origin: https://attacker.example.com.evil.com, Origin: https://example.com.attacker.example. Any of these reflected back is a finding.
Automated Scanning
ZeriFlow tests for CORS reflection, wildcard credentials, null-origin acceptance, and subdomain trust issues as part of its 80+ checks. Point it at your domain and any of those four patterns flag in the report. This is the fastest way to confirm your production CORS posture matches what you intended.
CI Integration
Add a contract test that asserts your CORS headers match an explicit expected list for a fixed set of origins. Fail the build if the headers drift.
For broader hardening of your API surface, our HTTP security headers guide covers CSP, HSTS, and the other headers that complement a tight CORS policy.
FAQ
Q: Is Access-Control-Allow-Origin: * ever safe?
A: Yes — for genuinely public, unauthenticated APIs that contain no sensitive data, served on a separate origin from anything authenticated. Public read-only data APIs, font CDNs, and image services use it correctly. Combine it with Access-Control-Allow-Credentials: false (or omit the header). Never combine it with credentials.
Q: Does CORS protect my API from being called?
A: No. CORS only restricts what browsers will let scripts read in cross-origin responses. Anyone can still call your API from a non-browser client (curl, Postman, a server). Authentication, rate limiting, and authorisation protect your API; CORS just prevents browser-based scripts from leaking authenticated responses.
Q: What's the difference between SameSite=Strict cookies and CORS?
A: They solve different parts of the same problem. SameSite controls when cookies are sent on cross-origin requests; CORS controls what cross-origin scripts can read. A defence-in-depth setup uses both: SameSite=Strict (or Lax) on session cookies, and a tight CORS allowlist for any cross-origin frontends.
Q: Should preflight requests be cached?
A: Yes. Set Access-Control-Max-Age to 600 (10 minutes) or higher to reduce preflight overhead. Browsers cap the cache at 7200 seconds (2 hours) regardless of what you set. Don't go too high — if you change your CORS policy, cached preflights mean clients won't see the change immediately.
Q: How do I handle CORS for multiple environments (dev, staging, prod)?
A: Maintain a per-environment allowlist in configuration, not in code. Development might allow http://localhost:3000; staging allows https://staging.example.com; production is locked to the production frontend. Never deploy a development CORS configuration to production.
Conclusion
CORS is a relaxation of the same-origin policy, not a replacement for it. The framework defaults are usually safe; the trouble starts when developers paste a wildcard or a regex from Stack Overflow to make a CORS error go away.
The recipe is short: explicit origin allowlist, credentials only when needed, narrow methods and headers, Vary: Origin set, and an automated scanner watching for regressions. Get those right and CORS goes from a recurring source of incidents to a non-event.
Start your free security scan on ZeriFlow → — CORS misconfiguration testing alongside 80+ other security checks. Free plan available, no credit card required.