A frontend calls an API. The request fails. The browser console lights up red: “Access to fetch at ‘https://api.example.com/v1/orders’ from origin ‘https://app.example.com’ has been blocked by CORS policy…”
The next thirty minutes typically involve a scramble through Stack Overflow answers, a copy-pasted middleware block, and a wildcard * that somebody will regret later. The real problem is almost never that the API is broken. It is that the browser has not been given a valid cross-origin permission response, and most fixes treat the symptom instead of the header that is actually missing.
CORS is not a difficult topic once the pieces are in the right order: what it protects, when preflight happens, how credentials change the rules, and which headers have to line up on the server side. This guide puts those pieces together in the order developers actually need them.
TL;DR
- CORS is enforced by browsers for cross-origin script requests. It does not stop curl, Postman, or server-to-server calls.
- The server has to return the right
Access-Control-*headers for the browser to expose the response. - Some requests trigger an
OPTIONSpreflight before the real request. - Credentials (cookies or HTTP auth via the browser) change the rules and disable the wildcard shortcut.
- The fastest path to a fix is to inspect the actual response headers the browser receives, not to guess from application code.
Check the raw response first. If the browser is not seeing the right Access-Control-* headers, no frontend retry logic will fix it. Use the CodeAva HTTP Headers Checker to fetch your live API endpoint and see exactly which headers arrive — after every proxy, CDN, and gateway in the chain.
The biggest misconception: who CORS is protecting
CORS does not protect your API. It protects your users. This one reframing eliminates most of the confusion around why the browser behaves the way it does.
CORS is a browser-enforced layer tied to the same-origin policy. It restricts what one website can read from another origin when a script is running in a user’s authenticated browser context. The concrete scenario it exists to stop:
- A user signs into
bank.example.comand has an active session cookie. - Later, the user visits
malicious-site.examplein another tab. - A script on the malicious site tries to
fetch("https://bank.example.com/accounts"). - Without CORS, the browser would send the request with the session cookie attached and hand the response body to the malicious script. CORS is the reason it does not.
What CORS does not do:
- It does not stop non-browser clients.
curl, Postman, mobile apps, and backend jobs ignore it entirely. - It does not replace authentication. A public endpoint is still public; CORS just controls who is allowed to read the response from a browser script.
- It does not replace CSRF protection. CSRF tokens, SameSite cookies, and careful method handling still matter.
- It does not replace authorisation. Your API still needs to verify that the caller is allowed to do what they are asking.
If you are thinking of CORS as a firewall, the rest of this article will make more sense once you replace that model with “browser-side permission response for cross-origin script access.”
What counts as a different origin
An origin is the combination of scheme, host, and port. Any difference in any of the three makes the request cross-origin.
https://app.example.comandhttps://api.example.com— different hosts, cross-origin.http://example.comandhttps://example.com— different schemes, cross-origin.https://example.comandhttps://example.com:8443— different ports, cross-origin.https://app.example.comandhttps://example.com— different hosts (subdomain counts), cross-origin.
“Same site” and “same origin” are not interchangeable, especially for cookies. This article is about CORS, which cares about origin.
Simple requests vs. preflighted requests
Not every cross-origin request triggers a preflight. Browsers define a safelist of methods, headers, and content types that are considered “simple.” Simple cross-origin requests are sent directly, and the browser decides whether to expose the response based on the Access-Control-Allow-Origin header that comes back.
A request is classified as simple when all of the following are true:
- The method is
GET,HEAD, orPOST. - The only manually set headers are on the CORS-safelisted list (such as
Accept,Accept-Language,Content-Language, and certain uses ofContent-Type). - The
Content-Type, if set, isapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.
Anything that does not satisfy every rule above triggers a preflight. In practice, this covers most modern API calls:
PUT,PATCH,DELETE— none of them are on the safelist.Content-Type: application/json— JSON APIs are preflighted.Authorizationor customX-headers— not safelisted; the preflight must explicitly allow them.
The rule is not “POST always preflights.” A plain HTML-form-style POST is a simple request. A JSON POST is not, because its content type is not on the safelist.
The preflight handshake: what actually happens
When a request requires preflight, the browser does not send the actual request first. It sends an OPTIONS request and waits for the server to confirm the intended operation is allowed.
- Browser prepares a cross-origin request using a method or headers that require preflight (for example,
PUTwithContent-Type: application/jsonand anAuthorizationheader). - Browser sends an
OPTIONSrequest to the same URL, including:Origin— the calling origin.Access-Control-Request-Method— the method the real request will use.Access-Control-Request-Headers— the non-safelisted headers the real request will include.
- Server responds with
Access-Control-*headers describing what is allowed:Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-Credentials(when credentials are involved)Access-Control-Max-Age(optional, caches the preflight result)
- Browser evaluates the response. If the origin, method, and headers are all acceptable, it proceeds. Otherwise it blocks the real request and surfaces a CORS error.
- Browser sends the actual request (for example, the
PUT) and handles the response as usual. - Browser decides whether to expose the response to the calling script based on the
Access-Control-Allow-Originon the actual response — the preflight only clears the method and headers, not the response exposure itself.
A concrete preflight exchange:
# Browser sends
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
# Server responds
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600After this, the browser sends the real PUT request. The server must still return Access-Control-Allow-Origin on that response for the browser to hand the body to the script.
Credentials change the rules
“Credentials” in CORS has a specific meaning: browser-managed authentication material such as cookies, HTTP Basic auth, or TLS client certificates. Manually setting an Authorizationheader with a bearer token is not the same thing — that is a custom header set by application code, not a browser credential.
With the fetch API, credentials are controlled by the credentials option:
omit— never send credentials.same-origin— only send on same-origin requests (the default).include— send even on cross-origin requests.
When a request is made with credentials: "include", the server has to respond with stricter, explicit CORS headers. Wildcards are no longer accepted.
The wrong pattern (browser will reject)
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueA wildcard origin combined with credentials is not allowed. Browsers reject the response and the request fails. The same restriction applies to Access-Control-Allow-Methods and Access-Control-Allow-Headers: for credentialed requests, wildcards are not honoured — you must list actual values.
The right pattern
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: OriginA few details that trip teams up:
Access-Control-Allow-Credentialshas only one valid value:true. Anything else (including absent) means credentials are not allowed. If the request is not credentialed, omit the header entirely.Access-Control-Allow-Originmust be a single origin (or dynamically echoed from an allowlist), not a list and not a wildcard.Vary: Origintells caches not to share the response across different calling origins. Without it, a CDN can serve the wrong origin’s response to another caller.
Wildcard + credentials is always wrong
Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: truetogether on the same response, the configuration is broken. Browsers will reject it, and any “working” behaviour you are seeing is probably from a non-credentialed request path that hides the real bug.Simple vs. preflighted vs. credentialed at a glance
| Request type | Triggered by | OPTIONS preflight? | Wildcard origin OK? |
|---|---|---|---|
| Simple | Safelisted method, headers, and content type. | No | Yes (no credentials) |
| Preflighted | Non-safelisted method, custom headers, or application/json. | Yes | Yes (no credentials) |
| Credentialed simple | Simple request with credentials: "include". | No | No— must echo specific origin. |
| Credentialed preflighted | Preflighted request with credentials: "include". | Yes | No— specific origin required in both preflight and actual response. |
The headers that have to line up
Most CORS errors in production come down to a mismatch between the request and the server’s response headers. Review the list below when you are looking at a failing call.
| Response header | What it does | Common mistake |
|---|---|---|
| Access-Control-Allow-Origin | Tells the browser which origin is allowed to read the response. | Missing entirely, set to a stale origin, or wildcard with credentials. |
| Access-Control-Allow-Methods | Lists allowed methods on preflight responses. | Only listing GET/POST when the app uses PUT/DELETE/PATCH. |
| Access-Control-Allow-Headers | Lists allowed non-safelisted request headers. | Forgetting Authorization, Content-Type, or custom X- headers the app actually sends. |
| Access-Control-Allow-Credentials | Indicates credentialed requests are allowed. | Set to true alongside a wildcard origin. |
| Access-Control-Expose-Headers | Lists response headers the script is allowed to read. | Not sending it when the client code reads custom response headers (rate limits, pagination, etc.). |
| Access-Control-Max-Age | Caches the preflight result for a period. | Missing entirely, forcing a preflight on every request. |
| Vary: Origin | Prevents caches from serving a response for one origin to a different origin. | Omitted when the server echoes the origin dynamically, leading to cache bleed. |
Common misconfigurations and how to fix them
Missing Access-Control-Allow-Origin on the real response
A preflight passed, but the actual GET or POST response does not include Access-Control-Allow-Origin. The browser blocks the script from reading it. Every cross-origin response the script needs to read must carry the header — not just the preflight.
Preflight responds with the wrong status or no CORS headers
A common bug: routing frameworks return 404 or 405 for OPTIONS because no explicit handler was registered. The browser treats that as a failed preflight. Make sure OPTIONS is handled globally and returns 204 (or 200) with the required Access-Control-* headers.
Forgotten headers in Access-Control-Allow-Headers
The preflight requests authorization, content-type but the response only allows content-type. Add every non-safelisted header the client actually sends. Header names are case-insensitive, but be consistent for readability.
Wildcards in the wrong place
Wildcards in Access-Control-Allow-Methods or Access-Control-Allow-Headersare not honoured for credentialed requests. The same applies to the origin. Echo the exact value instead — validate the incoming origin against an allowlist, and only reflect it in the response if it matches.
CDN or proxy stripping headers
The application sets Access-Control-*headers correctly. A CDN, WAF, or reverse proxy in front of the app strips or overrides them. This is invisible from inside the application — the only way to see it is to fetch the live URL from outside. The CodeAva HTTP Headers Checker is designed for exactly this verification. For the broader set of headers that proxies tend to mutate, see Security Headers Every Production Website Should Send.
Echoing Origin without validation
A quick fix that becomes a slow security problem: the server reads the Origin header from the request and echoes it back in Access-Control-Allow-Origin, unconditionally. This effectively allows every origin, including attackers’. Always validate against an explicit allowlist before reflecting.
OPTIONS preflight delayed by auth middleware
Some backend setups run authentication middleware before the CORS layer. When a browser preflight arrives without credentials (preflights are never credentialed), the auth layer returns 401 and the preflight fails. Make sure OPTIONS requests bypass auth middleware and return CORS headers directly.
Step-by-step CORS debugging checklist
Work through these steps in order the next time you see a CORS error. Each one eliminates a class of suspects.
- Confirm the error is actually CORS.Look at the browser console carefully. “Failed to fetch” without a CORS-policy message is usually network-level, not CORS.
- Check the origins.Compare the calling page origin (scheme + host + port) to the target API origin. Confirm they actually differ — sometimes the issue is a misrouted same-origin call.
- Classify the request. Is it simple or preflighted? Is it credentialed? Note the method, the content type, and any custom headers.
- Open the Network panel with “OPTIONS” visible. Find the preflight request and the actual request. Check the status and response headers of both.
- Confirm
Access-Control-Allow-Originis present on both the preflight and the actual response, and that it either exactly matches your origin or is*when credentials are not involved. - Confirm
Access-Control-Allow-Methodson the preflight response lists your method. - Confirm
Access-Control-Allow-Headerslists every custom header your request is sending. - If credentials are involved, verify
Access-Control-Allow-Credentials: true, no wildcards in origin/methods/headers, and that the client passedcredentials: "include". - Check for upstream mutation. Run the CodeAva HTTP Headers Checker against the live URL and compare with what the application is supposed to send. Any difference points to a proxy, CDN, WAF, or platform layer.
- Verify the custom response headers you read are in
Access-Control-Expose-Headers, if your client code reads anything beyond the default set. - Do not paper over the error with wildcards. Fix the specific header that is missing or mismatched. Record the fix in configuration so the next environment does not regress.
When CORS is not the right tool: proxying from your backend
Sometimes the API you are trying to call does not support CORS and will not add it. Sometimes exposing it cross-origin is the wrong architecture anyway — secrets, rate limits, and sensitive data often belong server-side.
In those cases, call the API from your own backend instead. Your frontend talks to your same-origin backend; your backend talks to the third-party API. CORS does not apply to server-to-server requests, and your backend can add authentication, caching, and validation cleanly. This is a legitimate, often safer, pattern — not a workaround.
Along the way, make sure your backend handles API errors properly. Unhandled promise rejections in TypeScript backends are a common source of silent failures in proxy layers; see Unhandled Promises and Production Bugs for the patterns that keep that class of issue under control.
CORS is one layer, not the whole picture
Because CORS controls cross-origin read access from browser scripts, it is easy to over-trust it. Two reminders worth repeating:
- CORS is not authorisation.Your API must still verify identity and permissions on every request. CORS lets you say “this origin’s scripts are allowed to read responses” — it does not say who the user is.
- CORS is not CSRF protection. Simple requests (including some form-like POSTs) can still be sent cross-origin with cookies attached. Use CSRF tokens,
SameSitecookies, and safe HTTP methods for state-changing operations.
Configure CORS carefully, then layer the rest of your cross-origin security on top of it. The response headers in this article are one part of a broader set of production security headers — for the complementary pieces, see Security Headers Every Production Website Should Send.
Read the response, not the middleware
Most CORS debugging sessions drag on because engineers keep arguing with their application configuration instead of looking at what the browser actually received. CORS is enforced on specific response headers. If those headers are wrong, no amount of retrying, wrapping, or middleware-swapping changes the outcome.
The working order is: understand what CORS is for, classify your request, confirm the preflight, match the headers, handle credentials correctly, and verify the live response. Wildcard shortcuts have their place for truly public endpoints. For everything else, specific origins, specific methods, and specific headers are the safer and more predictable configuration.
When you are ready to verify a specific endpoint, run the CodeAva HTTP Headers Checker against the live URL to see every Access-Control-* header the browser actually receives. Fix the specific header that is missing or mismatched, add it to your infrastructure configuration so the fix survives future deploys, and move on to building features again.





