CORS Error Calling Your Own API: The Fix

Browser blocks your fetch with a CORS error while curl works. Diagnose the real cause and fix it server-side, with verification.

You fetch('https://api.yourdomain.com/users') from the frontend and the console screams:

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

Meanwhile Postman or curl works fine. That single difference is the whole story: CORS is enforced by the browser, not the server. curl and Postman never send an Origin header and never honor the policy, so they sail through. The browser does, so it blocks the response after the fact.

Fastest fix: the missing piece is almost always server-side. Add a CORS middleware that returns Access-Control-Allow-Origin with your exact frontend origin (scheme + host + port, no trailing slash), and make sure the same headers come back on the OPTIONS preflight, not just the GET/POST. Jump to Shortest path to fix. If you just want to keep building locally without touching the backend, use a dev proxy instead of the old browser flag.

Mental model: for any “non-simple” cross-origin request (anything with a JSON body, a custom header, or a method other than GET/HEAD/POST), the browser first sends an OPTIONS preflight. The server must answer that preflight with Access-Control-Allow-Origin and friends. If the preflight fails, or the main response is missing the header, the browser rejects it and your .then() never runs.

Which bucket are you in

Open DevTools, go to Network, click the failing request (filter by Fetch/XHR, and look for a separate OPTIONS row), and match your symptom:

What you seeMost likely causeGo to
No Access-Control-Allow-Origin in the response at allServer has no CORS configCause 1
OPTIONS row returns 404 / 405 / 500Preflight not handledCause 2
Header present but value doesn’t equal your OriginOrigin string mismatchCause 3
Error says “must not be the wildcard ’*‘“credentials + wildcardCause 4
Error says “Request header field X is not allowed”Missing Allow-Headers/Allow-MethodsCause 5
Header present via curl, missing in browserCDN/proxy stripped itCause 6
Page prompts “allow access to your local network”, or request to localhost/LAN IP is blockedLocal Network Access permission (replaced the old PNA preflight)Cause 7

Common causes

Ordered by hit rate, highest first.

1. Server never sends Access-Control-Allow-Origin {#cause-1}

Most common. A fresh project has no CORS middleware, so every cross-origin request fails. The response is otherwise valid (200, correct body) — it just lacks the one header the browser requires.

How to spot it: Network -> the request -> Response Headers — no Access-Control-Allow-Origin line.

2. OPTIONS preflight isn’t handled {#cause-2}

Any request with a JSON Content-Type, a custom header (Authorization, X-API-Key), or a method of PUT/PATCH/DELETE triggers a preflight. If your routing returns 404/405 on OPTIONS (common when you only registered POST /users, not OPTIONS /users), the main request never fires.

How to spot it: Network shows a separate OPTIONS row with a non-2xx status. The browser expects 200 or 204.

3. Origin string mismatch (scheme / port / trailing slash) {#cause-3}

✅ Access-Control-Allow-Origin: https://app.example.com
❌ Access-Control-Allow-Origin: app.example.com          # missing scheme
❌ Access-Control-Allow-Origin: https://app.example.com/ # extra trailing slash
❌ Access-Control-Allow-Origin: http://app.example.com   # http vs https
❌ Access-Control-Allow-Origin: https://app.example.com:443  # explicit default port

The match is byte-for-byte against the request’s Origin header. One character off and the browser treats it as a mismatch. www vs non-www counts too.

How to spot it: Compare the response Access-Control-Allow-Origin value to the request’s Origin header — they must be identical strings.

4. credentials: include with wildcard * {#cause-4}

Browser rule: a credentialed request (cookies, Authorization, or client certs) requires a specific origin — never * — plus Access-Control-Allow-Credentials: true. The same restriction applies to Access-Control-Allow-Headers and Access-Control-Allow-Methods: with credentials, the wildcard * is treated as the literal string *, not “all”, so you must enumerate the real values.

How to spot it: Error contains The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

5. Missing Allow-Headers / Allow-Methods {#cause-5}

The frontend sends Content-Type: application/json or a custom X-API-Key — both trigger preflight. If the server doesn’t echo them back in Access-Control-Allow-Headers, the preflight fails before the real call.

How to spot it: Error contains Request header field x-api-key is not allowed by Access-Control-Allow-Headers in preflight response. (header name is lowercased in the message).

6. CDN / proxy strips CORS headers {#cause-6}

Cloudflare, nginx, or an API Gateway can drop or overwrite response headers. The origin server sends them correctly; the browser sees nothing. Caching layers can also serve a cached response that predates your CORS fix — or worse, cache one origin’s Access-Control-Allow-Origin and serve it to a different origin. If you reflect the request Origin (Cause 4 / Step 4 below), you must also send Vary: Origin so the cache keys on origin instead of mixing them.

How to spot it: curl the origin directly (bypass the CDN) and compare against what the browser sees through the public hostname.

7. Local Network Access blocks the localhost / private-IP call {#cause-7}

This one changed in 2026, so old advice is now wrong. When a page on the public internet (or any https:// page) calls a backend on a private address — localhost, 127.0.0.1, a 192.168.x.x / 10.x.x.x LAN IP, or a .local host — Chromium now gates the request behind Local Network Access (LNA), a browser-level permission prompt.

The important part: LNA replaced the older Private Network Access (PNA) scheme. PNA was put on hold, so the old fix — adding Access-Control-Allow-Private-Network: true to the server’s preflight response — no longer does anything in current Chrome. LNA is not opted into with a server header; the user grants it via a prompt (“<site> wants to access devices on your local network”). Chrome shipped the prompt in Chrome 141 (released Sep 30, 2025) and is rolling it out more widely in Chrome 142, so a localhost call that worked a year ago can suddenly fail after a Chrome update.

How to spot it: a permission prompt about your local network appears, or DevTools shows the request blocked with a Local Network Access message. The Access-Control-Allow-Private-Network response header no longer fixes it.

Fix: in dev, accept the permission prompt (the site only needs to be granted once per origin). For a deployed app that legitimately talks to a user’s LAN device, mark the request with targetAddressSpace so the prompt fires cleanly, and serve the page over HTTPS. See the Chrome Local Network Access notes. The cleanest workaround for normal frontend/backend dev is still a dev proxy, which keeps everything same-origin.

Shortest path to fix {#shortest-path-to-fix}

Step 1: Add CORS middleware server-side

// Express (cors v2)
import cors from 'cors';
app.use(cors({
  origin: [
    'https://app.yourdomain.com',
    'http://localhost:3000', // dev
  ],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  maxAge: 86400, // cache preflight 24h
}));
# FastAPI / Starlette
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.yourdomain.com", "http://localhost:3000"],
    allow_credentials=True,           # if True, do NOT use allow_origins=["*"]
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "X-API-Key"],
)
// Hono
import { cors } from 'hono/cors';
app.use('/*', cors({
  origin: ['https://app.yourdomain.com', 'http://localhost:3000'],
  credentials: true,
  allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  maxAge: 86400,
}));

Register the middleware before your routes, and before any auth middleware that might 401 the OPTIONS request.

Step 2: Handle OPTIONS preflight

Most CORS middleware answers OPTIONS automatically. If you roll your own, make sure the preflight returns 204 with the headers and never hits your auth guard:

app.options('/api/*', (req, res) => {
  res.set({
    'Access-Control-Allow-Origin': req.headers.origin,
    'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // cache 24h
  });
  res.sendStatus(204);
});

If your backend is on localhost or a LAN IP and the call is blocked, that is the separate Local Network Access permission, not a CORS header — see Cause 7. The legacy Access-Control-Allow-Private-Network response header no longer helps in current Chrome.

Step 3: Verify with curl

Simulate the exact preflight the browser sends and read the response headers:

curl -i -X OPTIONS https://api.yourdomain.com/users \
  -H "Origin: https://app.yourdomain.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

# A correct preflight returns 204 (or 200) with:
# Access-Control-Allow-Origin: https://app.yourdomain.com
# Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type

If the three Access-Control-Allow-* lines are present and the origin matches exactly, the browser will accept it.

Step 4: Handle credentials properly

If the frontend uses fetch(url, { credentials: 'include' }), you cannot use origin: '*' — reflect a specific allowed origin and set Access-Control-Allow-Credentials: true:

// Must enumerate; reflect only allowed origins
app.use(cors({
  origin: (origin, cb) => {
    const allowed = ['https://app.yourdomain.com', 'http://localhost:3000'];
    cb(null, allowed.includes(origin)); // false -> no header -> browser blocks
  },
  credentials: true,
}));

Access-Control-Allow-Origin only ever holds one origin or * — never a space-separated list — so reflecting the matched origin (as above) is the standard way to support several. When you reflect, also send Vary: Origin so a shared cache or CDN doesn’t hand one origin’s allow-header to another. The cors package and FastAPI’s CORSMiddleware add Vary: Origin for you; if you hand-roll the headers, add it yourself.

Step 5: CDN / proxy investigation

# Direct to origin (bypass CDN)
curl -i https://origin-server-ip/api/users \
  -H "Host: api.yourdomain.com" \
  -H "Origin: https://app.yourdomain.com"

# Through the public CDN hostname
curl -i https://api.yourdomain.com/users \
  -H "Origin: https://app.yourdomain.com"

If the headers are present direct and missing through the CDN, the edge stripped them. On Cloudflare, check Transform Rules -> Modify Response Header (and confirm a cached response isn’t being served — purge after the fix). On nginx, the response headers from proxy_pass are forwarded by default, but if you set any add_header Access-Control-* in the proxy block, you must add them on the OPTIONS branch too, because add_header does not inherit into a location once you declare one there.

Dev-only options {#dev-only-options}

The clean way to keep building locally without backend changes is a dev-server proxy, which makes the request same-origin from the browser’s point of view (no CORS at all). With Vite, add to vite.config.js:

// vite.config.js — calls to /api are proxied to the backend, no CORS
export default {
  server: {
    proxy: {
      '/api': { target: 'http://localhost:8000', changeOrigin: true },
    },
  },
};

Then fetch /api/users instead of the absolute backend URL. Create React App (proxy in package.json) and Next.js (rewrites() in next.config.js) have equivalents.

As a last resort for a quick one-off test, you can launch Chrome with web security off. As of June 2026 the flag is ignored unless you also pass --user-data-dir, and it disables real protections, so never browse normally in this profile:

# macOS — debugging ONLY
open -na "Google Chrome" --args \
  --user-data-dir="/tmp/chrome_dev" \
  --disable-web-security

This only changes your local browser; production must still serve correct CORS headers.

How to confirm it’s fixed

  1. The curl -X OPTIONS in Step 3 returns 204/200 with the matching Access-Control-Allow-Origin.
  2. In DevTools Network, the OPTIONS row is 204 and the real request is 200 with the Access-Control-Allow-Origin header on its response.
  3. Your fetch().then() actually runs in the page (no red console error), with a real Chrome profile — not the --disable-web-security one.

Prevention

  • Include the prod origin in the CORS allowlist during local dev so you don’t discover the gap on deploy day.
  • Drive the list from env: CORS_ORIGINS=https://app.example.com,http://localhost:3000, then split on ,.
  • Use one shared CORS helper — don’t write per-route configs that drift.
  • Cache preflight (Access-Control-Max-Age: 86400) to cut repeat OPTIONS traffic.
  • Never pair origin: '*' with credentials: true in production — the browser rejects it and it’s a real security risk.
  • When you reflect the request origin, always send Vary: Origin so a CDN never serves one origin’s allow-header to another.
  • After any API Gateway / CDN config change, run the curl OPTIONS smoke test and purge the edge cache.
  • Add a “CORS preflight 4xx” alert so a misconfig surfaces in monitoring, not in user reports.

FAQ

Why does curl/Postman work but the browser fails? curl and Postman don’t send an Origin header and don’t enforce CORS — only browsers do. A passing curl tells you the API responds; it tells you nothing about CORS. Reproduce CORS by adding -H "Origin: https://app.yourdomain.com" to your curl and checking for the Access-Control-Allow-* headers in the response.

Is CORS a server bug or a browser bug? Neither. CORS is a browser-enforced security model. The error appears in the browser, but the only durable fix is server-side: return the correct Access-Control-Allow-Origin (and the preflight headers) for your frontend’s origin.

Can I just set Access-Control-Allow-Origin: *? Only for a public, unauthenticated API. The moment your request sends cookies or an Authorization header (credentials: 'include'), the browser refuses * and demands a specific origin plus Access-Control-Allow-Credentials: true.

My GET works but POST is blocked — why? A simple GET often skips preflight; a POST with a JSON body or custom header triggers an OPTIONS preflight. You’re missing preflight handling, the method in Access-Control-Allow-Methods, or the header in Access-Control-Allow-Headers. Check the separate OPTIONS row in Network.

It worked last month and now Chrome blocks my localhost API. Likely Local Network Access (Cause 7). A public or https page calling a localhost / LAN-IP backend is now gated behind a browser permission prompt, not a CORS header. LNA replaced the older Private Network Access scheme, so adding Access-Control-Allow-Private-Network: true to the server does nothing now. Accept the prompt in dev, or proxy the call so it stays same-origin. Chrome shipped the prompt in 141 (Sep 2025) and is rolling it out more widely in 142.

The error only happens on production, not locally. Either your prod frontend origin isn’t in the allowlist (Cause 3), or a CDN/proxy is stripping the headers or serving a stale cached response (Cause 6). Curl the origin directly vs. through the public hostname to tell them apart.

External references: MDN — CORS errors and MDN — Access-Control-Allow-Origin.

Tags: #Backend #Debug #Troubleshooting #CORS