medium8 min readLast updated May 27, 2026

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: true if 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