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:
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 did | Fix |
|---|---|---|
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 page | Add 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 405 | Answer OPTIONS before routing and auth |
…It does not have HTTP ok status. | Sent CORS headers on the preflight, but a non-2xx status | Return 204 |
The 'Access-Control-Allow-Origin' header contains multiple values '…, …', but only one is allowed. | Two layers (app and nginx) both added it | Keep 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 site | Echo 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 cookies | Exact origin |
The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true'… | Cookies sent, but the credentials header missing | Access-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 list | Add 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 list | Add 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-Originmust be the exact origin, never*.Access-Control-Allow-Credentials: truemust be present.*inAccess-Control-Allow-Headers,-Methodsand-Expose-Headersno 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:
# 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;
}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;
}alwaysmatters most. Without it,add_headeronly 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
ifreturns 204 in the rewrite phase, beforeauth_basicor the app can reject the preflight; configuration insideifinherits the location’sadd_headerlines. - For an origin not in the map,
$cors_originis empty and nginx leaves the header out entirely. add_headeris inherited only by blocks that have none of their own. A nestedlocationthat adds, say,Cache-Controldrops all six lines; repeat them there. nginx 1.29.3 addsadd_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
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:
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 withTypeError: 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
$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
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:
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:
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: challengeheader instead of your API, and a preflight never carries the clearance cookie. WAF rules that refuseOPTIONS, and nginx’s 403 from alimit_except GET POSTblock, look the same. Cloudflare’s cache also ignoresVaryby default apart fromAccept-Encoding(only an Enterprise custom cache key that includes theOriginheader 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 onlyCache-Control,Content-Language,Content-Length,Content-Type,Expires,Last-ModifiedandPragma. In our test,res.headers.get('X-Request-Id')returnednullwith 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.
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.