Resolving “No ‘Access-Control-Allow-Origin’” in Chrome

Chrome blocks your cross-origin fetch() and prints:

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

Unlike Firefox’s CORS request did not succeed, this message carries a real status code and means a response did arrive — it simply lacked the Access-Control-Allow-Origin header the browser requires to hand the body to JavaScript. The fix is to make that header present on the actual response, and to make sure nothing downstream removes it.

This page is part of Decoding Browser CORS Error Messages, which decodes each browser’s console strings to the failing spec step; here we resolve Chrome’s missing-header case specifically.

Root Cause

The WHATWG Fetch Standard’s CORS check (§4.9) runs after the response is received: the browser looks for an Access-Control-Allow-Origin response header whose value equals the request’s Origin or is * (for non-credentialed requests). If the header is absent, the check fails and the browser discards the response body, raising this exact console error. Crucially, the header must be on the response the browser actually reads — for a real GET/POST, that is the actual response, not the preceding preflight. A configuration that adds CORS headers only to the OPTIONS branch passes preflight and still fails here, because the real response arrived bare. The complementary diagnostic workflow lives in debugging a missing Access-Control-Allow-Origin header; this page focuses on the Chrome string and the three places the header goes missing.

Prerequisite State

The Three Places the Header Goes Missing

Three points where Access-Control-Allow-Origin can be absent A left-to-right path: origin server, then proxy or CDN, then Chrome. Point one, the origin never emits the header. Point two, the origin emits it on OPTIONS only, not on the actual response. Point three, a proxy strips or duplicates the header in transit. Any of the three produces Chrome's No Access-Control-Allow-Origin error. Origin server emits headers Proxy / CDN forwards / mutates Chrome runs CORS check response travels right to left 1. Never emitted no CORS config at all 2. OPTIONS-only absent on real GET/POST 3. Stripped / duplicated removed or doubled in transit

Step-by-Step Fix

Step 1 — Confirm the origin emits the header on the actual method

curl the real request method (not just OPTIONS) with an Origin header:

curl -sS -D - -o /dev/null \
  -H "Origin: https://app.example.com" \
  https://api.example.com/v1/data

Look for access-control-allow-origin in the printed headers. If it is absent here, the origin is the problem — go to Step 2. If it is present, skip to Step 4 (a proxy is stripping it).

Step 2 — Emit the header on every response for the path (Express / Node.js)

Attach CORS headers in middleware that runs for all methods, before the route sends its response — not in an OPTIONS-only branch:

const express = require('express');
const app = express();

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

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    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');
    return res.sendStatus(204);
  }
  next();
});

app.get('/v1/data', (req, res) => res.json({ ok: true }));

Because the header is set before the OPTIONS short-circuit, it also attaches to the actual GET response. Reflecting an allowlisted origin follows dynamic origin validation patterns.

Step 3 — Or emit it in Nginx with the always flag

map $http_origin $cors_origin {
  default "";
  ~^https://(app|admin)\.example\.com$ $http_origin;
}

server {
  listen 443 ssl;
  server_name api.example.com;

  location /v1/ {
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Vary Origin always;

    if ($request_method = OPTIONS) {
      add_header Access-Control-Allow-Origin $cors_origin always;
      add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
      add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
      return 204;
    }

    proxy_pass http://backend_upstream;
  }
}

The always flag is essential: without it, Nginx drops add_header on 4xx/5xx responses, so the header vanishes exactly when an error occurs.

Step 4 — Find the proxy hop that strips or duplicates it

If curl to the origin shows the header but the browser does not, walk the hops. curl the origin directly, then curl through the public proxy URL, and compare:

# Direct to origin (bypass proxy)
curl -sS -D - -o /dev/null -H "Origin: https://app.example.com" \
  --resolve api.example.com:443:10.0.0.5 https://api.example.com/v1/data

# Through the proxy / CDN (public DNS)
curl -sS -D - -o /dev/null -H "Origin: https://app.example.com" \
  https://api.example.com/v1/data

If the header is present in the first and absent in the second, the proxy strips it — stop stripping it there. If the second shows the value twice, two layers both add it; de-duplicate per header deduplication techniques.

Verification

curl the actual method and confirm exactly one header value:

curl -sS -D - -o /dev/null -H "Origin: https://app.example.com" \
  https://api.example.com/v1/data | grep -i access-control-allow-origin

Expect a single line: access-control-allow-origin: https://app.example.com.

DevTools check:

Security Boundary Note

Do not respond with Access-Control-Allow-Origin: * when the request carries credentials. The Fetch spec forbids the browser from exposing a credentialed response under a wildcard, so pairing * with Access-Control-Allow-Credentials: true silently blocks the response and is nearly invisible without a deliberate test. Reflect the exact allowlisted origin instead. And never reflect the raw Origin header without an allowlist check — blind reflection turns your API into one that trusts every website a victim visits. See wildcard risks and mitigation for the full boundary.

Common Mistakes

Mistake Technical impact Fix
Header set only in the OPTIONS branch Preflight passes but the real GET/POST response is bare, so Chrome blocks it Set Access-Control-Allow-Origin for all methods before the OPTIONS short-circuit
Nginx add_header without always Header drops on 4xx/5xx, so errors appear as CORS failures Add always to every add_header Access-Control-* directive
Proxy strips the origin’s header curl to origin shows it, browser does not Configure the proxy to pass Access-Control-* through unchanged
Both proxy and origin add the header Chrome sees two values and rejects the response as malformed De-duplicate: strip upstream with proxy_hide_header before re-adding

FAQ

The preflight OPTIONS has the header but the real GET does not. Why?

The Fetch spec requires Access-Control-Allow-Origin on the actual response, not only on the preflight. Many setups add CORS headers in a dedicated OPTIONS branch or middleware that short-circuits before the real route runs, so the header never attaches to the GET or POST. Attach the header unconditionally to every response for the path, including non-OPTIONS methods and error responses.

curl shows Access-Control-Allow-Origin but Chrome still says it is missing. What now?

A proxy or CDN between the browser and origin is stripping or duplicating the header, or it appears only on a redirect target Chrome did not follow for CORS. Test each hop: curl the origin directly, then curl through the proxy. Where the header disappears is the layer to fix. Duplicate values also fail, because the spec allows exactly one.

Can I just set Access-Control-Allow-Origin to a wildcard to make it work?

Only for requests without credentials. If the request sends cookies or an Authorization header, Chrome rejects a wildcard and requires the exact origin plus Access-Control-Allow-Credentials: true. Reflecting an allowlisted origin is safer than a wildcard even for public endpoints, because it keeps the response scoped and Vary-cacheable.