Troubleshooting

CORS error: fix “blocked by CORS policy” on the server

A CORS error means the browser blocked your page from reading another origin's response. Fix it on the server: send Access-Control-Allow-Origin, answer OPTIONS.

Published ~10 min read

Short answer: a CORS error means the browser received a response from another origin but refused to hand it to your JavaScript, because the response lacked an Access-Control-Allow-Origin header naming your page’s origin, or because the preflight OPTIONS request failed. You fix it on the server that sends the response, not in the frontend: return that header, answer OPTIONS with a 2xx status and the allowed methods and headers, and send the headers on error responses too. This shows what the server returns to a browser:

Linux · macOS · the preflight a browser sends
curl -sS -i -X OPTIONS https://api.example.com/v1/items \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization, content-type'

Use your API’s URL and your page’s origin exactly as in the address bar: scheme, host and port, no path, no trailing slash. A working answer has a 2xx status and one access-control-allow-origin line equal to that origin, or *.

Which CORS message do you see?

Chrome and Edge start every CORS error with Access to fetch at 'URL' from origin 'ORIGIN' has been blocked by CORS policy: (Access to XMLHttpRequest at for XHR and axios), then one of these reasons. All are real output from Chrome 153 against a test API:

Reason after “blocked by CORS policy:”What the server didFix
No 'Access-Control-Allow-Origin' header is present on the requested resource.Sent no allow-origin header: not configured, or this was an error page, a redirect or a CDN pageAdd it, on errors too (traps)
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present…Answered OPTIONS without CORS headers, often a 404, 401 or 405Answer OPTIONS before routing and auth
…It does not have HTTP ok status.Sent CORS headers on the preflight, but a non-2xx statusReturn 204
The 'Access-Control-Allow-Origin' header contains multiple values '…, …', but only one is allowed.Two layers (app and nginx) both added itKeep one
The 'Access-Control-Allow-Origin' header has a value '…' that is not equal to the supplied origin.Allowed another origin: http vs https, www, port, or a cached answer for another siteEcho the matching origin, add Vary: Origin
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'.* on a request with cookiesExact origin
The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true'…Cookies sent, but the credentials header missingAccess-Control-Allow-Credentials: true
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.The header is not in the allowed listAdd it to Access-Control-Allow-Headers
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.The method is not in the allowed listAdd it to Access-Control-Allow-Methods
…Redirect is not allowed for a preflight request.The URL redirects (HTTP to HTTPS, trailing slash, www)Call the final URL

Older Chrome versions append a hint to set the request’s mode to no-cors. Ignore it: that gives you an opaque response whose status reads 0 and whose body you cannot read. Firefox writes Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at … with a reason and, when the header is missing or the preflight failed, the status code; Safari says Origin … is not allowed by Access-Control-Allow-Origin. Status code: …. If that status is a 500 or 502, the server failed and CORS is only the messenger.

In Chrome your script only sees TypeError: Failed to fetch, which a dead connection throws too. If the console shows net::ERR_CONNECTION_REFUSED or ERR_CONNECTION_RESET instead of a CORS line, no response arrived at all, and no header will fix it.

What the browser checks, and what it does not

An origin is scheme, host and port. https://app.example.com and https://api.example.com are two origins, and so are http://localhost:5173 and http://localhost:3000. For a request to another origin the browser adds an Origin header, and before your code sees the response it checks the Access-Control-* headers. Two consequences are easy to miss:

  • The request often reaches your server anyway. In our test, a blocked form POST arrived and ran on the server; the browser only withheld the answer. Only a failed preflight (below) stops the real request. curl, Postman and server-side code ignore CORS, which is why the same call “works in Postman”.
  • CORS is not access control. An origin you do not allow still gets the full response from the server; only browsers refuse to show it. Protect the API with authentication.

A request goes out directly only if it is a GET, HEAD or POST, sets no headers beyond the safelisted ones (Accept, Accept-Language, Content-Language, Content-Type, a simple Range), and its Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else, such as PUT, DELETE, a JSON body, or an Authorization header, is preceded by an OPTIONS preflight carrying Access-Control-Request-Method and Access-Control-Request-Headers. The real request is only sent after a 2xx answer that allows both. Browsers cache that answer for Access-Control-Max-Age seconds: 5 by default, at most 7200 in Chromium and 86400 in Firefox.

Cookies and credentials: when * stops working

With fetch(url, { credentials: 'include' }) or axios’s withCredentials: true the browser sends cookies and requires more:

  • Access-Control-Allow-Origin must be the exact origin, never *.
  • Access-Control-Allow-Credentials: true must be present.
  • * in Access-Control-Allow-Headers, -Methods and -Expose-Headers no longer means “all”; list the names.
  • Send Vary: Origin, so caches keep one origin’s answer from reaching another.

A token you set yourself in an Authorization header is not a credential in this sense: GitHub’s API answers with * and lists Authorization explicitly, as shown below. Do the same: the Fetch standard says a * in Access-Control-Allow-Headers never covers Authorization. Chrome 153 still let it through in our test, listing a deprecation in DevTools’ Issues tab, and Firefox logs a warning that it will stop.

Fix CORS in nginx

When nginx sits in front of your API, let it own the CORS headers: one place, and it answers preflights without bothering the app. The map belongs in the http block (a file in /etc/nginx/conf.d/ works), the location in the API’s server block:

nginx · /etc/nginx/conf.d/cors-map.conf
# Origins allowed to read the API's responses (exact match)
map $http_origin $cors_origin {
    default                   "";
    "https://app.example.com" $http_origin;
    "http://localhost:5173"   $http_origin;
}
nginx · inside the API's server { } block
location /api/ {
    # on every response, errors included ("always")
    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, OPTIONS" always;
    add_header Access-Control-Allow-Headers     "Authorization, Content-Type" always;
    add_header Access-Control-Max-Age           7200 always;
    add_header Vary                             Origin always;

    # answer the preflight here; the app never sees it
    if ($request_method = OPTIONS) {
        return 204;
    }

    # the app must not send its own copies
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Credentials;

    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}
  • always matters most. Without it, add_header only touches 200, 201, 204, 206, 304 and redirects, so every 401, 404, 422 and 500 arrives without CORS headers and the browser reports a CORS error instead of the real status.
  • The if returns 204 in the rewrite phase, before auth_basic or the app can reject the preflight; configuration inside if inherits the location’s add_header lines.
  • For an origin not in the map, $cors_origin is empty and nginx leaves the header out entirely.
  • add_header is inherited only by blocks that have none of their own. A nested location that adds, say, Cache-Control drops all six lines; repeat them there. nginx 1.29.3 adds add_header_inherit merge, but Debian 12 ships 1.22 and Ubuntu 24.04 ships 1.24.

For a public API without cookies, skip the map: use add_header Access-Control-Allow-Origin * always; and drop the credentials and Vary lines. Test and reload with sudo nginx -t && sudo systemctl reload nginx.

Fix CORS in Express

Node.js · Express 5 with the cors package
const express = require('express');
const cors = require('cors');

const app = express();

// before every route and before auth: it also answers the OPTIONS preflight
app.use(cors({
  origin: ['https://app.example.com', 'http://localhost:5173'],
  credentials: true, // only if the browser sends cookies
  maxAge: 7200,
}));

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

app.listen(3000);

Real answer of this setup (Express 5.2, cors 2.8.6) to the preflight from the top of this page, shortened:

Example output · curl -i -X OPTIONS …
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin, Access-Control-Request-Headers
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 7200
  • Without allowedHeaders, the middleware allows whatever headers the preflight asks for. Origins not in the list get no allow-origin header, but still the full response, as described above.
  • Old guides add app.options('*', cors()). Express 5 refuses to start with TypeError: Missing parameter name at index 1: *; visit https://git.new/pathToRegexpError for info. app.use(cors()) already answers every preflight; if you need the route, write '/{*splat}'.
  • Errors keep their CORS headers because the middleware runs first. If nginx adds headers as well, remove one of the two.

Fix CORS in PHP

PHP · first lines of the API's entry script
<?php
$allowed = ['https://app.example.com', 'http://localhost:5173'];
$origin  = $_SERVER['HTTP_ORIGIN'] ?? '';

header('Vary: Origin');
if (in_array($origin, $allowed, true)) {
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Access-Control-Allow-Credentials: true');
}

// answer the preflight before routing and auth
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
    header('Access-Control-Allow-Headers: Authorization, Content-Type');
    header('Access-Control-Max-Age: 7200');
    http_response_code(204);
    exit;
}

It must run before any output; afterwards header() only warns Cannot modify header information - headers already sent. In our test with PHP 8.2, an uncaught exception further down still returned its 500 with these headers. Laravel answers preflights itself with its HandleCors middleware: run php artisan config:publish cors, list your origins under allowed_origins in config/cors.php, and check that paths covers the route.

Fix CORS in Caddy

Caddyfile
api.example.com {
	header +Vary Origin

	@cors {
		header Origin https://app.example.com
		header Origin http://localhost:5173
	}
	header @cors {
		>Access-Control-Allow-Origin "{header.Origin}"
		>Access-Control-Allow-Credentials "true"
		>Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
		>Access-Control-Allow-Headers "Authorization, Content-Type"
		>Access-Control-Max-Age "7200"
	}

	@preflight {
		method OPTIONS
		header Origin https://app.example.com
		header Origin http://localhost:5173
	}
	respond @preflight 204

	reverse_proxy 127.0.0.1:3000
}

Caddy runs header before respond and reverse_proxy, so the headers reach both the preflight and the proxied answers. Lines for the same header inside a matcher are OR’ed, so each allowed origin gets its own line, and {header.Origin} echoes the one that matched. The > prefix sets a field when the response is written, replacing any copy from the app.

Debug CORS with curl

Send what the browser sends, then read the access-control-* lines. For the preflight use the command at the top; for the real request:

Linux · macOS · the actual request
curl -sS -o /dev/null -D - https://api.example.com/v1/items \
  -H 'Origin: https://app.example.com' | grep -iE '^(HTTP|location|access-control|vary)'

A correct preflight answer, real output from GitHub’s public API, shortened:

Example output · OPTIONS https://api.github.com/zen
HTTP/2 204
access-control-max-age: 86400
access-control-allow-headers: Authorization, Content-Type, If-Match, If-Modified-Since, …
access-control-allow-methods: GET, POST, PATCH, PUT, DELETE
access-control-allow-origin: *

Check four things: a 2xx status, no location line, exactly one allow-origin line (grep -ci '^access-control-allow-origin' prints 1), and that it equals your origin character for character. In Chrome DevTools, the Network tab lists the preflight as its own row; its status is often the real answer.

Our HTTP header checker requests the API URL from Amsterdam and lists every redirect, the status and all response headers. It sends no Origin, so it shows CORS headers only if your server sends them unconditionally, but it reveals the two silent culprits: a redirect, and a WAF or bot protection page answering instead of your API.

Traps that look like CORS bugs

  • Error responses without the headers. A 500 from an app whose error handler runs before the CORS code, or an nginx 502 while the app is down, shows up as a CORS error. Fix the status, then add always.
  • Redirects. A preflight never follows one. A simple request does only if the redirect itself carries CORS headers, and after a redirect to another origin the browser sends Origin: null, which we saw in our test and which allow-lists reject. Call the final URL: HTTPS, the right host, the trailing slash your framework wants.
  • Duplicate headers. App and nginx, or nginx and a CDN rule, both set the header, and Chrome rejects '*, https://app.example.com' as multiple values. One layer owns CORS.
  • Proxies and CDNs. A Cloudflare challenge answers with an HTML page and a cf-mitigated: challenge header instead of your API, and a preflight never carries the clearance cookie. WAF rules that refuse OPTIONS, and nginx’s 403 from a limit_except GET POST block, look the same. Cloudflare’s cache also ignores Vary by default apart from Accept-Encoding (only an Enterprise custom cache key that includes the Origin header changes that), so a cached file keeps the allow-origin of whoever fetched it first. Send * for public static files, or keep per-origin responses out of the cache.
  • Headers your code cannot read. Without Access-Control-Expose-Headers, scripts see only Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. In our test, res.headers.get('X-Request-Id') returned null with no error at all.

The cleanest fix: no cross-origin request at all

If you run both frontend and API, serve the API under the frontend’s own origin, for example https://app.example.com/api/, with nginx proxying that path to the API. Same origin means no CORS headers, no preflights, and cookies that just work. During development, Vite’s server.proxy option does the same.

nginx · in the frontend's server { } block
location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}

What not to do: mode: 'no-cors' (opaque, unreadable), browser flags or extensions that switch the check off (they only fix your own browser), or a public CORS proxy, which sees every token and cookie you pass through it. What a reverse proxy does explains the pattern behind the fix above.

The complete server block with Let’s Encrypt is in our nginx reverse proxy guide.

A VMHeaven VPS in Amsterdam or Frankfurt runs the API and nginx side by side with full root, so the CORS headers live in one config you control.

Frequently asked

How do I fix a CORS error?

On the server that answers the request, not in the frontend. Send Access-Control-Allow-Origin with your page's exact origin (or * for a public API without cookies), answer the OPTIONS preflight with 204 and the allowed methods and headers, and make sure error responses carry the same headers.

Why does my API call work in Postman or curl but not in the browser?

CORS is enforced only by browsers. Postman, curl and server-side code send the request and read the answer whatever the headers say. A browser withholds the response from your JavaScript unless Access-Control-Allow-Origin allows your page's origin, and before a JSON, PUT or Authorization request it sends an OPTIONS preflight that must pass.

Can I use Access-Control-Allow-Origin: * with cookies?

No. When a request is sent with credentials (fetch with credentials: 'include', axios withCredentials), the browser rejects *. The server must return the exact origin and Access-Control-Allow-Credentials: true, should add Vary: Origin, and should check the origin against an allow-list instead of reflecting any.

Related articles

All troubleshooting