</>CodeWithKarani

Blocked by CORS Policy: Stop Reaching for Access-Control-Allow-Origin: *

Karani GeoffreyKarani Geoffrey9 min read

The front end is on app.example.com, the API is on api.example.com, and the browser console has gone red. You search the error, and the first ten results all say the same thing: add Access-Control-Allow-Origin: *. You add it. It works. You ship.

Then someone adds login, cookies start travelling with the requests, and the same endpoint breaks again with a different CORS error. So you search again, and this time the answer is to echo back whatever Origin the request sent. That also works. That one is worse than the wildcard, and almost nobody says so.

Here is the thesis: CORS is not a bug in your API and it is not a thing you turn off. It is you telling the browser which other websites are allowed to read your responses. * means "every website on the internet, and I promise this data is public". Reflecting the request's Origin means the same thing, except you have also enabled it for authenticated requests, which is a design the specification deliberately forbids you from writing directly.

keep an explicit allowlist on the server, compare the incoming Origin against it, and echo back only a value that matched.

  • Wildcard * is fine for genuinely public, credential-free endpoints: CDN assets, open data, public read APIs with no cookies and no Authorization header.
  • Never combine Access-Control-Allow-Credentials: true with *. The spec forbids it and browsers reject it.
  • Never reflect req.headers.origin without checking it against a list. With credentials enabled that is a full cross-site account-read vulnerability.
  • Always send Vary: Origin when the header value depends on the request, or a cache will serve one site's permission to another.
  • The preflight OPTIONS response needs the same allowlist logic, plus Access-Control-Allow-Methods and Access-Control-Allow-Headers, and must return a 2xx.

The exact errors

Chrome and Edge, the one everybody searches:

Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.

The preflight variant, which means the OPTIONS request failed before your real request was ever sent:

Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access control
check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

And the one you get the moment credentials enter the picture, in Firefox's wording:

Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'

Chrome says the same thing at more length, complaining that the value must not be the wildcard when the request's credentials mode is include. This is the fork in the road. What you do next determines whether you have a working API or a security incident with a deadline.

Why the browser is doing this

An origin is the triple of scheme, host and port. https://app.example.com and https://api.example.com are different origins. So are http://localhost:3000 and http://localhost:8000, which is why this bites on day one of every project.

The same-origin policy says JavaScript on one origin cannot read responses from another. CORS is the opt-out: the server attaches headers saying which origins are permitted, and the browser enforces the answer. Two consequences people miss:

  • The request usually still reaches your server. For a simple request, the browser sends it, your handler runs, your database is written to, and then the browser refuses to hand the response body to the calling script. CORS is not an access control on your API. It is a rule about who can read the reply.
  • It is a browser rule only. curl, Postman, your mobile app and any attacker's script running outside a browser ignore CORS entirely. If your endpoint needs protecting, it needs authentication and authorisation. CORS is not that.

A request is preflighted when it goes beyond the simple set: any method other than GET, HEAD or POST; a Content-Type other than application/x-www-form-urlencoded, multipart/form-data or text/plain; or any custom header. Sending JSON with Content-Type: application/json preflights, which is why nearly every real API triggers it.

browser api.example.com 1. OPTIONS /orders/42 Origin, Access-Control-Request-Method: PUT 2. 204, allowlist matched Allow-Origin: app.example.com, Vary: Origin 3. PUT /orders/42 the real request, with cookies 4. 200 + response body Allow-Origin and Allow-Credentials repeated Step 4 must repeat the headers. A correct preflight followed by a bare response still fails. If auth middleware rejects the OPTIONS at step 1 with a 401, the console reports a missing Allow-Origin header. Wrong diagnosis, every time.
Almost all "it works in Postman" reports come from step 1. Postman never sends a preflight.

The dangerous fix nobody warns you about

When the wildcard stops working because you need cookies, the internet's next suggestion is this:

// DO NOT DO THIS
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', req.headers.origin)
  res.header('Access-Control-Allow-Credentials', 'true')
  next()
})

Read it as a policy statement rather than as code and it says: any website that asks is allowed to read authenticated responses from this API. It is functionally a wildcard, except the wildcard is legal with credentials because you built it out of a variable.

The attack is three lines. A user who is logged into your app opens an unrelated page:

// on https://totally-not-evil.example
const r = await fetch('https://api.example.com/me/invoices', { credentials: 'include' })
console.log(await r.json())   // exfiltrate

The browser attaches your user's session cookie because it is a request to your domain. Your server reflects https://totally-not-evil.example into Access-Control-Allow-Origin, sets Allow-Credentials: true, and the browser dutifully hands the JSON to the attacker's script. No XSS on your domain required. No CSRF token helps, because this is a read.

SameSite=Lax cookies, which are the default in current browsers, blunt the cookie half of this. They do not help at all when the credential is an Authorization header, or when you have set SameSite=None because your app is genuinely cross-site, which is exactly the situation that made you reach for reflection. Treat SameSite as a second layer, never as the reason it is fine.

The pattern that is actually correct

Step 1: Write down the allowlist

Not a regex. A list. Regexes for origins are a reliable source of holes, because https://app.example.com.evil.net matches more patterns than people expect.

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
  'http://localhost:3000',
])

Keep localhost entries behind an environment check so they do not reach production.

Step 2: Echo only a matched value, and set Vary

app.use((req, res, next) => {
  const origin = req.headers.origin
  res.setHeader('Vary', 'Origin')

  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin)
    res.setHeader('Access-Control-Allow-Credentials', 'true')
  }

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE')
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
    res.setHeader('Access-Control-Max-Age', '600')
    return res.sendStatus(204)
  }
  next()
})

Three details that are doing real work:

  • No header at all on a non-match. Do not send Access-Control-Allow-Origin: null and do not return a 403 page with CORS headers on it. Absence is the correct answer, and the browser will block it.
  • Vary: Origin is set unconditionally, including on the non-match path. If you only set it when you match, a shared cache can store the no-header version and replay it to your legitimate front end.
  • The OPTIONS branch runs before authentication. Mount this middleware above your auth middleware. A preflight carries no cookies your auth layer will accept, so an auth check that runs first returns 401 and the browser reports it as a missing CORS header.

Step 3: Or do it at the edge, in nginx

If your API sits behind nginx, keeping CORS in one place beats scattering it across services:

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

server {
    location /api/ {
        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,PATCH,DELETE" always;
            add_header Access-Control-Allow-Headers     "Content-Type,Authorization" always;
            add_header Access-Control-Max-Age           600 always;
            add_header Vary                             Origin always;
            return 204;
        }

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

        proxy_pass http://app_upstream;
    }
}

An empty $cors_origin makes nginx omit the header entirely, which is the behaviour you want for an origin that is not on the list. The always flag matters: without it the headers are dropped on error responses, so a 500 from your upstream turns into a confusing CORS error in the console instead of the 500 it is. If nginx is new territory, the reverse proxy config I actually run covers the surrounding setup.

Step 4: Pick the right policy per endpoint

EndpointAllow-OriginAllow-Credentials
Public CDN assets, fonts, open data*absent
Public read API, no auth ever*absent
Your own SPA calling your own API with cookiesmatched origin onlytrue
Partner API with bearer tokensmatched origin onlyabsent, tokens are sent explicitly
Internal admin APImatched origin onlytrue

Verification

Test it with curl, not with your app. Positive case first:

curl -i -X OPTIONS https://api.example.com/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type,authorization"
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-credentials: true
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE
access-control-allow-headers: Content-Type,Authorization
access-control-max-age: 600
vary: Origin

Now the test that matters, the negative case:

curl -i -X OPTIONS https://api.example.com/orders \
  -H "Origin: https://totally-not-evil.example" \
  -H "Access-Control-Request-Method: PUT" | grep -i "access-control-allow-origin"

Expected output: nothing. No line at all. If that command prints access-control-allow-origin: https://totally-not-evil.example, you are reflecting, and if allow-credentials: true is anywhere in the response you have the vulnerability described above and it is a today problem, not a backlog problem.

Finally, confirm the real response carries the headers too, since a passing preflight followed by a bare 200 still fails in the browser:

curl -sI https://api.example.com/orders -H "Origin: https://app.example.com" | grep -i "access-control\|vary"

What people get wrong

"Set it to *, it is just a dev thing." Wildcards ship. They ship on internal admin APIs, on endpoints returning customer records, on anything the person fixing the error at 11pm was not thinking about. If the endpoint could ever return data specific to a logged-in user, * is wrong even before credentials are involved, because it invites the reflection fix later.

"Just reflect the Origin, it is the same thing." It is the same thing, and that is the problem. The wildcard at least fails loudly when you add credentials. Reflection fails silently, in your users' favour right up until it does not.

"Allow null so file:// and sandboxed iframes work." Any sandboxed iframe on any site can produce Origin: null. Allowlisting it is allowlisting the internet with extra steps.

"Launch Chrome with --disable-web-security." This turns a real integration problem into a local illusion, and the bug reappears in staging with less time to fix it. If you want cross-origin friction gone in development, proxy instead: Next.js rewrites, Vite's server.proxy and CRA's proxy field all make the API same-origin for the browser. That is a legitimate technique, not a workaround, and it is often the right production answer too.

"CORS protects my API." It does not. It is enforced only by browsers, on the response, after your handler has already run. Authentication and authorisation protect your API. If you are still designing that layer, Six API Design Mistakes covers the ones that cost the most later.

When it is still broken

  1. Duplicate headers. If both your application and your reverse proxy add CORS headers, the browser sees two values and refuses: the message complains that the header contains multiple values but only one is allowed. Pick one layer and remove the other. curl -I shows both.
  2. The preflight is not 2xx. Framework routers frequently have no OPTIONS route and return 404 or 405. Check the status code of the OPTIONS request directly, before reading anything into the CORS message.
  3. A header is missing from Allow-Headers. Adding X-Request-Id or X-Tenant on the client silently breaks the preflight until the server lists it. The browser tells you which one it wanted in the request's Access-Control-Request-Headers.
  4. A cache in front of you. Without Vary: Origin, a CDN or shared proxy caches the response including its Access-Control-Allow-Origin and serves it to a different origin. The symptom is an error that comes and goes depending on which point of presence you hit, which is easy to blame on flaky networks when you are testing from Nairobi against an edge in Frankfurt.
  5. You need a response header the client cannot see. JavaScript can only read a small default set of response headers cross-origin. Anything else, including pagination counts and rate-limit headers, requires Access-Control-Expose-Headers. This is not an error, just a value that is mysteriously null.

The rule I hold to: every value in Access-Control-Allow-Origin should be a string a human deliberately wrote down somewhere, not a string that arrived in a request. Once you accept that, the rest of CORS is bookkeeping.

Frequently asked questions

Why does Access-Control-Allow-Origin: * stop working when I send cookies?
The CORS specification forbids combining the wildcard with credentials, and browsers enforce it. Firefox reports it as 'Credential is not supported if the CORS header Access-Control-Allow-Origin is *', and Chrome says the value must not be the wildcard when the request's credentials mode is include. The fix is to compare the incoming Origin against an explicit allowlist on the server and echo back only a value that matched.
Is reflecting the request's Origin header back a safe way to fix CORS?
No, it is the most dangerous common fix. Echoing req.headers.origin without an allowlist check, together with Access-Control-Allow-Credentials: true, tells the browser that any website may read authenticated responses from your API. A page a logged-in user visits can then fetch your endpoints with credentials included and read the JSON, with no XSS on your domain and no CSRF token able to stop it, because it is a read.
Why do I need Vary: Origin on CORS responses?
Because the Access-Control-Allow-Origin value depends on the request's Origin header, so a shared cache or CDN that ignores that dependency will store one origin's response and serve it to another. The symptom is a CORS error that appears and disappears depending on which cache node you hit. Set Vary: Origin on every response, including ones where no allow-origin header is sent.
Does CORS protect my API from attackers?
No. CORS is enforced only by browsers, and only on the response, after your handler has already run and any side effects have happened. curl, Postman, mobile apps and server-side scripts ignore it entirely. Protecting an API is the job of authentication and authorisation; CORS only decides which other websites' JavaScript may read the reply.
#CORS#Security#Nginx#APIs#HTTP
Keep reading

Related articles