CORS errors: why a browser blocks a request and how to allow it
How CORS preflight requests work, what the Access-Control-Allow headers must say, and how to fix a request a browser blocks.
Browsers don’t let a script on one site read responses from another site unless that site says it may. CORS (Cross-Origin Resource Sharing) is how it says so: a few Access-Control-Allow-* headers in its responses. When they’re missing or don’t match, the browser keeps the answer from the script and logs a CORS error, while the same request from curl works fine.
An origin is a scheme, host, and port, such as https://app.example.com. A script on https://app.example.com calling https://api.example.com is cross-origin, though both are on example.com.
What SignalDNS checks
In the redirect tracer, you can enter an origin, the method your script uses (GET, POST, PUT, PATCH, or DELETE), and any headers it sends. After tracing the address, we send the final address what a browser would send first, without following a redirect:
- When the request needs a preflight, an
OPTIONSrequest withOrigin,Access-Control-Request-Method, andAccess-Control-Request-Headersnaming the headers that need permission. That’s any method other than GET or POST, or any header other thanAccept,Accept-Language, andContent-Language. We assume a listedContent-Typeis one that needs a preflight, such asapplication/json. - When it doesn’t, a
GETwithOrigin, since the browser checks the request’s own answer. For a POST, we send GET in its place, so nothing on your server changes. Check that POST answers with the same headers.
Then we apply the browser’s rules in order, and report the first that fails:
- For a preflight, the status is 2xx. Without one, any status counts, since a script can read an error response too.
Access-Control-Allow-Originis your origin, or*without credentials.- For a preflight, the method is listed, unless it’s GET, HEAD, or POST, which never need to be.
- For a preflight, every header that needs permission is allowed.
The answer says which kind of request a browser sends, and why. A blocked answer needs attention, since we can’t know whether that’s what you want. When everything passes, we note whether credentials are allowed, whether Vary: Origin is missing, and how long browsers may reuse a preflight’s answer.
How a preflight works
Before a request that a plain HTML form couldn’t make, the browser asks first. A PUT with a JSON body and a token looks like this:
OPTIONS /orders/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
The server answers with what it allows:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Vary: Origin
If the answer allows the request, the browser sends the real one, and its response needs Access-Control-Allow-Origin too. Access-Control-Max-Age lets the browser reuse the answer for that many seconds. Without it, the Fetch standard sets 5 seconds, and MDN notes that Firefox caps it at 24 hours and Chromium at 2 hours. A preflight never carries cookies or an Authorization header.
Some requests skip the preflight. MDN calls them simple requests: GET, HEAD, or POST, with only CORS-safelisted headers, and a Content-Type, if any, of application/x-www-form-urlencoded, multipart/form-data, or text/plain. For those, the browser sends the request straight away and checks Access-Control-Allow-Origin on the response before the script can read it. A JSON Content-Type or an Authorization header always means a preflight.
CORS doesn’t protect your server
CORS is applied by browsers, to protect their users. curl, Postman, and other servers ignore it, and can call your API whatever your CORS headers say. A simple request still reaches your server even when the browser then hides the response. So CORS isn’t access control. Protect the API with authentication; CORS only decides which websites’ scripts may read its responses in a visitor’s browser.
The preflight doesn’t get a 2xx answer
The OPTIONS request got an error or a redirect. Browsers need a status from 200 to 299 before they send the real request, and don’t follow a redirect on a preflight. We send one only when the request you described needs it, so a plain GET is never judged on this. Causes include:
- No route for OPTIONS, so the server or framework answers 404 or 405.
- Authentication runs first. The preflight carries no cookies or token, so it gets 401 or 403.
- A redirect, such as adding a trailing slash or switching to
https://. Call the final address directly. - A firewall or WAF rule that blocks
OPTIONS.
How to fix it: answer OPTIONS with 204 and the Access-Control-Allow-* headers, before any login check. In nginx:
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE";
add_header Access-Control-Allow-Headers "authorization, content-type";
add_header Access-Control-Max-Age 600;
add_header Vary Origin;
return 204;
}
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Vary Origin always;
proxy_pass http://127.0.0.1:3000;
}
always adds the header to error responses too, so the browser shows your API’s error instead of a CORS error. An add_header inside a block drops the ones inherited from outside it.
In Express, the cors middleware answers preflights for every route when used with app.use. Put it before your authentication middleware:
import cors from 'cors';
app.use(cors({
origin: ['https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['authorization', 'content-type'],
maxAge: 600,
}));
The answer has no Access-Control-Allow-Origin
The server answered without Access-Control-Allow-Origin, which browsers read as “not allowed”. After a preflight, the browser doesn’t send the real request. For a request without one, the browser sends it, but the script can’t read the answer. CORS may not be set up at all, or only on some handlers. Next.js, for example, answers OPTIONS for a route handler on its own by setting an Allow header, which CORS doesn’t use.
How to fix it: send Access-Control-Allow-Origin: https://app.example.com on the preflight and on the real response. For a public API that never uses cookies, * works. In a Next.js route handler:
const cors = {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'authorization, content-type',
Vary: 'Origin',
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: cors });
}
export async function PUT(request: Request) {
return Response.json({ saved: true }, { headers: cors });
}
The answer allows a different origin
Access-Control-Allow-Origin names an origin, but not yours. Browsers compare it exactly, so these all fail: https://example.com for https://www.example.com, http:// for https://, a trailing slash (https://app.example.com/), or a missing port. The header holds one origin, never a list.
How to fix it: keep a list of allowed origins on the server. When the request’s Origin is on it, send that origin back, with Vary: Origin. In nginx, with a map in the http block:
map $http_origin $cors_origin {
default "";
"https://app.example.com" "https://app.example.com";
"https://admin.example.com" "https://admin.example.com";
}
Then use add_header Access-Control-Allow-Origin $cors_origin always;. nginx skips the header when the value is empty. Don’t send back whatever origin arrives, especially with credentials, which the Fetch standard warns needs great care.
Any origin is allowed, along with credentials
The answer has Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. Browsers don’t accept * for a request that sends cookies or other credentials, such as fetch(url, { credentials: 'include' }). Without credentials, * works and the credentials header is ignored. A server that allows credentials expects requests that send them, so we report it as blocked.
How to fix it: if your script doesn’t need cookies, drop Access-Control-Allow-Credentials. If it does, name the origin instead of *, add Vary: Origin, and keep Access-Control-Allow-Credentials: true, in lower case: the Fetch standard compares it exactly. With credentials, * in the methods and headers lists is taken literally too, so list them by name.
The method isn’t allowed
You chose PUT, PATCH, or DELETE, and Access-Control-Allow-Methods doesn’t list it. GET, HEAD, and POST are CORS-safelisted and never need listing. * covers every method, but only without credentials. The Allow header doesn’t count; browsers read only Access-Control-Allow-Methods.
How to fix it: add the method to the preflight answer:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
A request header isn’t allowed
A header you entered isn’t in Access-Control-Allow-Headers. Examples are Authorization, Content-Type (application/json isn’t a safelisted value), and custom headers such as X-Request-Id. Names aren’t case-sensitive. * covers other headers only without credentials, and never covers Authorization, which always has to be listed by name.
How to fix it:
Access-Control-Allow-Headers: authorization, content-type, x-request-id
Vary: Origin is missing
When the answer names an origin, the right value depends on who’s asking. A CDN or the browser’s own cache could keep the answer for one origin and hand it to a request from another, which then fails. Vary: Origin tells caches to keep a separate copy per origin. MDN recommends it whenever Access-Control-Allow-Origin names an origin instead of *.
When the request is allowed, we note a missing Vary: Origin without counting it.
How to fix it: send Vary: Origin on every response that sets Access-Control-Allow-Origin to a named origin, including errors.
We couldn’t send the preflight
There was no answer to the preflight or the request within 10 seconds, or the server couldn’t be reached. Or the address isn’t public, and we don’t send requests to private addresses. This is “Couldn’t check”: it says nothing about your CORS settings. If it keeps failing, see why a website doesn’t load.
Setting it up in your CMS or framework
- Express: cors middleware
- nginx: add_header and map
- Apache: mod_headers
- Next.js: route handlers, and proxy for CORS across many routes
- Cloudflare Workers: CORS header proxy
- AWS: CORS for REST APIs in API Gateway
- AWS: CORS in Amazon S3
What we can’t tell from outside
We send one request from our server, for the origin, method, and headers you enter. A browser decides from what your script really sends, including the exact Content-Type, and a server may answer us differently. After a preflight, we don’t send the real request, so we can’t see whether its response has the headers it needs. For a POST without a preflight, we read the answer to a GET. And we can’t tell whether your script sends credentials, so we don’t flag a named origin without Access-Control-Allow-Credentials.
Sources
- Fetch Standard, section 3.3 for the CORS protocol and credentials, and 4.8 for the CORS-preflight fetch
- MDN: Cross-Origin Resource Sharing (CORS)
- MDN: Access-Control-Allow-Origin, for
Vary: Origin - MDN: Access-Control-Allow-Headers, for
Authorizationand* - MDN: Access-Control-Max-Age
- MDN: CORS-safelisted request header
Last reviewed .