CORS Misconfiguration: How Overly Permissive Cross-Origin Policies Expose Your Users
CORS misconfigurations let malicious websites access your APIs on behalf of users. Learn how to detect insecure CORS headers and configure them correctly.
What is CORS and why does it exist?
Cross-Origin Resource Sharing (CORS) is a browser security mechanism that controls which websites can make requests to your server. It exists because of the Same-Origin Policy -- a fundamental browser security rule that prevents a web page on one domain from reading responses from a different domain.
Without the Same-Origin Policy, any website you visit could silently make requests to your bank, email, or corporate applications using your authenticated session and read the responses. The Same-Origin Policy blocks this.
CORS is the controlled exception. It lets your server explicitly declare which other origins are allowed to access its resources. When configured correctly, CORS is harmless. When misconfigured, it punches a hole in the Same-Origin Policy.
How CORS works
When a browser makes a cross-origin request (e.g., JavaScript on evil.com fetching data from api.yourcompany.com), the browser adds an Origin header:
GET /api/user/profile HTTP/1.1
Host: api.yourcompany.com
Origin: https://evil.com
Cookie: session=abc123
The server responds with CORS headers that tell the browser whether the request is allowed:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: true
If the Access-Control-Allow-Origin header matches the requesting origin and Access-Control-Allow-Credentials is true, the browser allows evil.com to read the response -- including any data returned using the user's session cookie.
Dangerous CORS misconfigurations
Reflecting any origin
The most common and most dangerous misconfiguration: the server reads the Origin header from the request and reflects it back in the Access-Control-Allow-Origin response header.
# DANGEROUS: reflects any origin
@app.after_request
def add_cors(response):
origin = request.headers.get('Origin')
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
This is functionally equivalent to removing the Same-Origin Policy entirely. Any website can access your API using your users' credentials.
Wildcard with credentials
Setting Access-Control-Allow-Origin: * allows any origin, but browsers will not send credentials (cookies, authorisation headers) with wildcard CORS. However, some server frameworks work around this by detecting the Origin header and switching between * and the specific origin -- which reintroduces the reflection problem.
Note: Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true is technically rejected by browsers, but some frameworks emit this anyway and older browsers may handle it inconsistently.
Null origin trust
Some servers allow the null origin:
Access-Control-Allow-Origin: null
Access-Control-Allow-Credentials: true
The null origin can be triggered from sandboxed iframes, local files, and certain redirect chains. Trusting null means an attacker can craft a page that sends requests with an origin of null and read responses.
Regex mistakes in origin validation
Partial string matching on origins is a common source of bypass:
# DANGEROUS: substring match
if "yourcompany.com" in origin:
allow(origin)
# Bypassed by: evilyourcompany.com, yourcompany.com.evil.com
# DANGEROUS: startswith match
if origin.startswith("https://yourcompany"):
allow(origin)
# Bypassed by: https://yourcompany.evil.com
# DANGEROUS: regex without anchoring
if re.match(r"https://.*\.yourcompany\.com", origin):
allow(origin)
# Bypassed by: https://evil.yourcompany.com.attacker.com
Trusting all subdomains
# RISKY: trusts any subdomain
if origin.endswith(".yourcompany.com"):
allow(origin)
This is safer than the above patterns but still risky if any subdomain is compromised or controlled by an attacker (via subdomain takeover, XSS on a subdomain, or user-controlled subdomains). A compromised subdomain can now access every CORS-protected endpoint.
How to test for CORS misconfiguration
Manual testing with curl
# Test if an arbitrary origin is reflected
curl -sI https://api.yourcompany.com/user/profile \
-H "Origin: https://evil.com" | grep -i "access-control"
# Test null origin
curl -sI https://api.yourcompany.com/user/profile \
-H "Origin: null" | grep -i "access-control"
# Test subdomain bypass
curl -sI https://api.yourcompany.com/user/profile \
-H "Origin: https://evil.yourcompany.com" | grep -i "access-control"
# Test with credentials
curl -sI https://api.yourcompany.com/user/profile \
-H "Origin: https://evil.com" \
-H "Cookie: session=test" | grep -i "access-control"
If the response includes Access-Control-Allow-Origin: https://evil.com with Access-Control-Allow-Credentials: true, the server is vulnerable.
Automated testing
Tools that check CORS misconfigurations:
- CORScanner -- automated CORS misconfiguration scanner
- Burp Suite -- intercept and modify Origin headers during web testing
- OWASP ZAP -- includes CORS checks in active scanning
How to configure CORS correctly
Use an explicit allowlist
Define exactly which origins are allowed and validate against the full origin string:
ALLOWED_ORIGINS = {
"https://app.yourcompany.com",
"https://www.yourcompany.com",
"https://admin.yourcompany.com",
}
@app.after_request
def add_cors(response):
origin = request.headers.get('Origin')
if origin in ALLOWED_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Vary'] = 'Origin'
return response
Key points:
- Exact string match -- no regex, no substring matching
- Set
Vary: Origin-- tells caches that the response varies by origin, preventing cache poisoning - Only include
Allow-Credentials: trueif you actually need it -- if the endpoint does not use cookies or auth headers, omit it
Nginx configuration
# Map allowed origins
map $http_origin $cors_origin {
default "";
"https://app.yourcompany.com" $http_origin;
"https://www.yourcompany.com" $http_origin;
}
server {
location /api/ {
if ($cors_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, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Vary "Origin" always;
}
# Handle preflight requests
if ($request_method = OPTIONS) {
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, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
add_header Vary "Origin" always;
return 204;
}
}
}
For more Nginx configuration guidance, see our Nginx security hardening guide.
When to use wildcard CORS
Access-Control-Allow-Origin: * (without Allow-Credentials: true) is appropriate for truly public APIs that:
- Do not use cookies or session-based authentication
- Return only public data
- Are designed to be accessed from any website (CDN content, public datasets, open APIs)
location /api/public/ {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
# Do NOT add Access-Control-Allow-Credentials
}
Limit allowed methods and headers
Only allow the HTTP methods and headers your API actually needs:
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type
Do not use wildcards for methods or headers unless you have a specific reason.
Set a reasonable preflight cache
The Access-Control-Max-Age header tells browsers how long to cache preflight (OPTIONS) responses. A value of 86400 (24 hours) reduces the number of preflight requests:
Access-Control-Max-Age: 86400
CORS and your broader security headers
CORS is one part of your HTTP security header configuration. For a complete security headers implementation including Content-Security-Policy, HSTS, X-Frame-Options, and more, see our HTTP security headers guide.
How SurfaceScan helps
SurfaceScan tests CORS configuration on every web endpoint it discovers. It sends requests with various Origin values -- including attacker-controlled domains, null, and subdomain variants -- and flags servers that reflect arbitrary origins with credentials enabled. Findings include the specific misconfiguration detected, the endpoint affected, and configuration guidance for your web server or framework. This catches CORS issues that are easy to miss in development but trivially exploitable in production.
Related articles
Exposed API Endpoints: How Unsecured APIs Become Your Biggest Attack Vector
Exposed API endpoints without authentication or rate limiting are a top attack vector. Learn how to discover, audit, and secure your APIs before attackers do.
HTTP Security Headers: What They Are and How to Add Them
HTTP security headers like HSTS, CSP, and X-Frame-Options protect your site from clickjacking, XSS, and MIME sniffing. Learn how to add them on Nginx and Apache.
Nginx Security Hardening Checklist: A Practical Configuration Guide
Harden your Nginx web server with this practical checklist. Covers version hiding, security headers, TLS configuration, rate limiting, and a complete example config.