Skip to content

SecurityPart of: Security headers

Access-Control-Allow-Origin: what CORS allows and when * is dangerous

Access-Control-Allow-Origin tells browsers which other sites may read a response. What CORS is, when the * wildcard is fine, when it leaks data, and how to write a safe allowlist.

getReport teamUpdated 4 Oct 202611 min read

Access-Control-Allow-Origin is the response header that tells a browser which other websites may read a response with JavaScript. It is the core of CORS (Cross-Origin Resource Sharing) and of your site's CORS policy, which is simply the set of Access-Control-* response headers your server sends. Access-Control-Allow-Origin: https://app.example.com lets scripts on that one origin read the response, and * lets any website read it. The wildcard is fine for fonts and public data, and a leak on anything that answers differently per visitor. This guide explains how CORS works, which setting is dangerous, and how to write an allowlist on nginx, Apache, Express, Next.js and WordPress. CORS is one of the response-header topics in HTTP security headers from zero to A.

Quick answer

  • CORS is a browser rule about reading cross-origin responses. It is not a firewall; anyone can still curl your API.
  • * is fine for resources that are the same for everyone and need no cookie: fonts, public JSON, static files.
  • * is a leak on anything that varies per visitor or is reachable only inside a network (intranet, VPN, IP allowlist).
  • Browsers refuse * together with Access-Control-Allow-Credentials: true. The pattern that actually leaks is a server that echoes any Origin and adds credentials.
  • Safe pattern: an explicit list of origins, echo only a listed origin, send Vary: Origin, answer preflights. Check the page with the security headers checker and the API with curl -H "Origin: …".

How CORS works

A browser keeps sites apart. JavaScript on evil.example can send a request to bank.example, and the browser may even attach the visitor's bank.example cookies, but the script is not allowed to read the response. That is the same-origin policy. An origin is scheme, host and port together: https://example.com and https://api.example.com are different origins.

CORS is how a server relaxes that rule on purpose. The browser sends the page's origin in an Origin request header; the server answers with Access-Control-Allow-Origin; if the value is * or matches the page's origin, the script may read the response.

For requests that could change data in ways a plain form cannot (a PUT, a DELETE, a POST with a JSON body, custom headers such as Authorization), the browser first sends a preflight: an OPTIONS request with Access-Control-Request-Method and Access-Control-Request-Headers. The server must answer with the matching Access-Control-Allow-Methods and Access-Control-Allow-Headers, or the real request never goes out. Access-Control-Max-Age lets the browser cache that answer.

Two consequences follow:

  1. CORS only governs browsers. A response with no CORS headers is still fully readable with curl or a server-side fetch. It protects data from other websites running in your visitors' browsers, not from attackers.
  2. The visitor's browser has the visitor's cookies and network position. A wrong header lets an attacker's page borrow the visitor's session, or their place inside a corporate network, to read something the attacker could not fetch directly.

What is a CORS policy?

A CORS policy is not a file or a single setting. It is whatever Access-Control-* headers your server, framework or CDN sends with a response, and the visitor's browser reads them on every cross-origin request. Sending none is also a policy, and the strictest one: no other website may read your responses.

Response headerWhat it tells the browser
Access-Control-Allow-OriginWhich origin may read the response: one exact origin, or * for any.
Access-Control-Allow-Credentialstrue lets the script read responses to requests sent with cookies. Never valid with *.
Access-Control-Allow-MethodsThe methods allowed after a preflight, such as GET, POST, PUT.
Access-Control-Allow-HeadersThe request headers allowed after a preflight, such as Content-Type, Authorization.
Access-Control-Expose-HeadersExtra response headers the script may read, such as X-Total-Count.
Access-Control-Max-AgeHow many seconds the browser may reuse a preflight answer.

The headers are defined in the WHATWG Fetch standard; MDN's CORS guide lists them with examples. When the policy does not allow a read, the browser blocks it and logs a console message such as "has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource". The fix belongs on the server that answered, not in the page that asked: add the calling origin to an allowlist as in step 3 below, and never answer with * plus credentials.

When * is harmless and when it is dangerous

A response that is identical for everyone, fetched without cookies, has nothing to hide. * is correct for:

  • Web fonts on a CDN. Browsers fetch @font-face files in CORS mode, so a font on cdn.example used by example.com needs a CORS header.
  • Public, unauthenticated APIs: exchange rates, a weather feed, a public search.
  • Static JSON, images drawn on a <canvas>, and scripts loaded with crossorigin="anonymous".

* is risky when the endpoint:

  • returns something personalised (/api/me, /cart, /account/orders). With * the browser strips credentials, so most of these answer "not logged in"; some frameworks identify visitors another way and still leak;
  • sits on an intranet, a VPN or behind an IP allowlist. A malicious page opened on the VPN can read the dashboard through the visitor's browser;
  • answers based on IP, such as region or rate-limited previews.

The check warns on * on the page itself because HTML pages are not cross-origin API resources; a wildcard there usually means a blanket rule that also covers every API route.

The actually dangerous pattern: reflection with credentials

The Fetch standard forbids * together with Access-Control-Allow-Credentials: true. Developers who hit that error sometimes "fix" it like this:

Text
Access-Control-Allow-Origin: <whatever the Origin request header said>
Access-Control-Allow-Credentials: true

Now every origin is allowed and cookies are attached. A page on evil.example fetches https://shop.example/api/me with credentials: 'include', the browser sends the session cookie, the server echoes evil.example, and the attacker's script reads the visitor's name, address and orders. No scanner that reads a plain response sees it, because the header only appears when an Origin is sent.

How getReport checks it

The checker fetches the page like a browser navigation, follows redirects, and reads Access-Control-Allow-Origin and Access-Control-Allow-Credentials on the final page. * alone is a warning; * with Access-Control-Allow-Credentials: true is a fail, because the server is configured to allow credentials to everyone. An explicit origin, or no header, passes.

The CORS wildcard finding opened on a page that answers Access-Control-Allow-Origin: * to everyone: the finding title, the evidence line with the origin and credentials values, the plain-language explanation of what any site can read, and the fix lines
The evidence line shows both headers; credentials set to true turns the warning into a fail.

Two neighbouring findings are part of the same defence. SameSite decides whether a cookie is attached to cross-site requests at all, so a Lax session cookie limits the damage of a reflection bug; a CSP connect-src limits where your pages may send data:

Note

A navigation carries no Origin header, so a server that reflects origins shows no Access-Control-Allow-Origin in the response the checker reads, and passes. Test APIs by hand as in step 2.

Step by step

1. Find where the header comes from

grep the server config, application code and CDN rules for Access-Control-Allow-Origin. Common sources: a site-wide nginx add_header meant for fonts, a Header set in .htaccess copied from a forum, app.use(cors()) with no options in Express, a WordPress plugin that "fixes" REST errors, or a CDN "CORS" toggle on the whole zone.

2. Test the API for reflection

Send an origin you do not own:

Shell
curl -sI -H "Origin: https://evil.example" https://example.com/api/me \
  | grep -i -E "^access-control-|^vary:"
  • * with no credentials line: harmless if the endpoint is public; otherwise change it.
  • https://evil.example plus Access-Control-Allow-Credentials: true: reflection with credentials. Fix today.
  • A fixed origin such as https://app.example.com, or no header: not readable from evil.example.

Repeat for a preflight:

Shell
curl -si -X OPTIONS -H "Origin: https://evil.example" \
  -H "Access-Control-Request-Method: POST" https://example.com/api/orders \
  | grep -i -E "^HTTP|^access-control-"

3. Write the allowlist

If the incoming Origin is on the list, echo exactly that origin; otherwise send no Access-Control-Allow-Origin. Always add Vary: Origin so a cache does not serve one origin's response to another. Send Access-Control-Allow-Credentials: true only if the front end really uses cookies cross-origin.

nginx (the map goes in the http block):

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

server {
    location /api/ {
        # nginx drops add_header lines whose value is empty, so unknown origins get nothing
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Vary Origin always;

        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, PUT, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
            add_header Access-Control-Max-Age 86400 always;
            return 204;
        }
        proxy_pass http://app:3000;
    }
}

Apache (mod_headers and mod_setenvif):

Apache
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 merge Vary "Origin"

Express, with the cors package, which handles preflights and Vary:

JavaScript
const cors = require('cors');

app.use('/api', cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));

Never pass a function that returns true for every origin, and never origin: '*' with credentials: true.

Next.js, in a route handler such as app/api/me/route.js:

JavaScript
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);

function corsHeaders(request) {
  const origin = request.headers.get('origin') ?? '';
  const headers = { Vary: 'Origin' };
  if (ALLOWED.has(origin)) {
    headers['Access-Control-Allow-Origin'] = origin;
    headers['Access-Control-Allow-Credentials'] = 'true';
  }
  return headers;
}

export async function GET(request) {
  return Response.json({ ok: true }, { headers: corsHeaders(request) });
}

export function OPTIONS(request) {
  return new Response(null, {
    status: 204,
    headers: { ...corsHeaders(request), 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' },
  });
}

4. Give fonts their own rule

If the site-wide wildcard was for a font on another host, scope it to font files on that host:

nginx
location ~* \.(woff2?|ttf|otf)$ {
    add_header Access-Control-Allow-Origin "*" always;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

Platform notes

WordPress. The REST API at /wp-json/ sends CORS headers from core: when a request carries an Origin, WordPress echoes it and adds Access-Control-Allow-Credentials: true. That is the reflection pattern, with one built-in mitigation: cookie authentication in the REST API only counts when the request also carries a valid nonce in X-WP-Nonce, which a cross-site page cannot obtain. Without it the request is treated as logged out. To tighten anyway, a must-use plugin at wp-content/mu-plugins/rest-cors.php:

PHP
<?php
/**
 * Plugin Name: REST API CORS allowlist
 */
add_action('rest_api_init', function () {
    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
    add_filter('rest_pre_serve_request', function ($served) {
        $allowed = ['https://app.example.com'];
        $origin  = get_http_origin();
        if ($origin && in_array($origin, $allowed, true)) {
            header('Access-Control-Allow-Origin: ' . esc_url_raw($origin));
            header('Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE');
            header('Access-Control-Allow-Credentials: true');
            header('Vary: Origin', false);
        }
        return $served;
    });
}, 15);

Plugins that promise to "enable CORS" for headless setups often set * on every response, including the HTML, which is where the check catches it.

Shopify and hosted platforms. Storefront pages send no CORS header and you cannot add one; apps use the Storefront API with their own tokens.

Static sites. Scope the wildcard to the font folder in Netlify's _headers (/fonts/*) or Vercel's vercel.json (/fonts/(.*)), never /*. Where those files live is in Netlify and Vercel headers and caching.

Verify

  1. Re-run the security headers checker: "Access-Control-Allow-Origin is not a wildcard".
  2. The reflection test with a made-up origin prints no Access-Control-Allow-Origin, or a fixed origin that is not the one you sent.
  3. The same test with a listed origin echoes it, with Vary: Origin.
  4. The real front end still works: log in, load the account page, place a test order. A console "blocked by CORS policy" message now names the origin you forgot to list.

Common mistakes

  • * on every response to silence one font error. Scope it to the font files.
  • Echoing the Origin header "to support all our domains". That allows all domains. Compare against a list, then echo.
  • A prefix match. origin.startsWith('https://app.example.com') also matches https://app.example.com.evil.example. Compare whole origins.
  • Allowing null. Sandboxed iframes and file:// pages send Origin: null; allowing it lets an attacker's sandboxed frame in.
  • No Vary: Origin behind a CDN. The cache serves one origin's answer to every origin.
  • Treating CORS as CSRF protection. CORS controls reading, not sending; a cross-site form can still POST with cookies. The defences are SameSite cookies and CSRF tokens, covered in Secure, HttpOnly and SameSite cookie flags.

Questions people ask

What is CORS?

CORS (Cross-Origin Resource Sharing) is the browser mechanism that lets a server say which other websites may read its responses with JavaScript. By default a script on one origin cannot read a response from another. When the server answers with Access-Control-Allow-Origin naming the calling site, or *, the browser allows the read. It controls browsers only; it does not stop direct requests.

Does CORS protect my API from attackers?

No. CORS only tells browsers whether a script on another website may read your response. An attacker can call your API directly with curl, a script or a server, and CORS headers change nothing. Protect the API with authentication, authorisation and rate limits. CORS matters for a narrower risk: another site using your visitors' browsers, and their cookies, to read data.

Why do I need Vary: Origin with CORS?

Because the Access-Control-Allow-Origin value changes with the request's Origin. Without Vary: Origin, a CDN or browser cache can store the answer made for one site and serve it to another, which then fails with a CORS error or, worse, gets access it should not have. Send Vary: Origin on every response whose CORS headers depend on the origin.

What is a CORS preflight request?

A preflight is an OPTIONS request the browser sends before a cross-origin request that uses a method other than GET, HEAD or POST, a JSON body or custom headers. It asks the server whether that method and those headers are allowed. The server must answer with matching Access-Control-Allow-Methods and Access-Control-Allow-Headers, or the browser never sends the real request.

Check your site before and after Check