Every frontend dev sees this at least once — Access to fetch at '...' has been blocked by CORS policy. This guide explains exactly what CORS (Cross-Origin Resource Sharing) blocks and allows, which requests trigger a preflight, and where to look first when things break.
Why CORS exists — the Same-Origin Policy
Browsers stop JavaScript from reading responses fetched from a different origin. Origin = scheme + host + port.
https://app.example.com↔https://api.example.com— different origins (host differs)https://example.com↔http://example.com— different origins (scheme differs)https://example.com↔https://example.com:8443— different origins (port differs)
Without this rule, any open tab could call fetch("https://your-bank.com/balance") with your cookies and read the response. SOP is the foundation of every web security model.
If a URL's origin is unclear, drop it into URL Parser to see protocol/host/port broken out.
What CORS does
CORS is a mechanism for relaxing SOP. The server adds certain response headers (Access-Control-Allow-Origin, etc.) and the browser then lets JS read the response.
Two facts that matter:
- The request always reaches the server. CORS doesn't stop the request; it stops JS from reading the response. (Preflight OPTIONS, however, can fail before the actual request.)
- CORS only applies inside browsers. curl, Node.js, Postman, mobile apps all ignore it. The server still answers regardless of CORS headers.
Simple request vs preflight
The browser splits requests into two flows depending on what they look like.
Simple request — sent directly
If all of the following are true, the browser sends the actual request and inspects response headers afterwards.
- method ∈ {GET, HEAD, POST}.
- custom headers limited to:
Accept,Accept-Language,Content-Language,Content-Type(restricted),Range. Content-Type∈ {application/x-www-form-urlencoded,multipart/form-data,text/plain}.- No
ReadableStreambody, no extrafetch()options that change the semantics.
Most REST API calls don't qualify — JSON bodies use Content-Type: application/json, which sits outside the simple set, so a preflight fires automatically.
Preflight — OPTIONS first
When the request doesn't qualify as simple, the browser sends an OPTIONS request first.
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorizationThe server answers with:
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: content-type, authorization
Access-Control-Max-Age: 86400If preflight passes, the actual request follows. Access-Control-Max-Age caches the result so subsequent identical requests skip OPTIONS. Chrome caps at 7200 seconds (2h), Firefox at 24h.
Response headers at a glance
| Header | Role | Example |
|---|---|---|
Access-Control-Allow-Origin | Allowed origin | https://app.example.com or * |
Access-Control-Allow-Methods | Allowed methods (preflight) | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Allowed custom headers | content-type, authorization |
Access-Control-Allow-Credentials | Send cookies / auth | true |
Access-Control-Expose-Headers | Response headers JS can read | X-Total-Count, X-Page |
Access-Control-Max-Age | Preflight cache (seconds) | 86400 |
Cookies and credentials
To send cookies with fetch(url, { credentials: "include" }) or XMLHttpRequest.withCredentials = true:
- The server returns
Access-Control-Allow-Credentials: true. Access-Control-Allow-Originmust be a concrete origin, never*— wildcards are forbidden in credentialed requests.*is similarly ignored inAccess-Control-Allow-HeadersandAccess-Control-Allow-Methodsfor credentialed requests.
Don't forget the SameSite cookie attribute. Cookies on cross-site requests need SameSite=None; Secure. SameSite=Lax only allows top-level navigation.
Common errors and fixes
1. No 'Access-Control-Allow-Origin' header is present
The server simply doesn't return a CORS header. Add Access-Control-Allow-Origin on the backend. Express: cors middleware. FastAPI: CORSMiddleware. Spring: @CrossOrigin.
2. The 'Access-Control-Allow-Origin' header has a value '*' which must not be used when the request's credentials mode is 'include'
Sending cookies with a wildcard origin. Echo the request's Origin header back as Access-Control-Allow-Origin after validating it. Add Vary: Origin so any cache splits per origin.
3. Request header 'authorization' is not allowed
The preflight response is missing that header. Add authorization to Access-Control-Allow-Headers.
4. 200 OK in Network tab but JS errors
Response arrived but the browser blocks JS access because of missing CORS headers. Look in the Console for the CORS message. Reproduce with curl (build the command in cURL Builder) — curl ignores CORS and shows the response, useful for diagnosing whether the server actually returned the data.
5. Preflight returns 401/403
The backend requires auth on OPTIONS. OPTIONS must always pass without authentication. Check middleware order — CORS handler before auth.
6. Wildcard subdomains
https://*.example.com isn't a standard value. Match the request's Origin server-side via regex and echo it back if it passes.
Configuration vs security — common myth
"Loosening CORS opens up security holes" is a misconception. CORS is a browser-side guard; it doesn't replace server-side authentication. That said:
Access-Control-Allow-Origin: *+ authenticated response = bad. Any site can read the API response on behalf of your users.- Public, no-auth, read-only API →
*is fine. - Credentialed API → strict origin allowlist required.
Workarounds — when CORS gets in the way
- Same-origin via proxy — expose the API under
/api/*on your own origin. Simplest fix. - Server-side fetch — Next.js Route Handlers, Server Actions, Cloudflare Workers, etc. fetch the upstream API and the client only talks to its own origin.
- JSONP — the legacy hack. Conflicts with modern CSP policies; avoid.
Summary
- SOP blocks JS from reading other origins. CORS is the server's way of opting in.
- Simple-request rules are tight — JSON POSTs already trigger preflight.
- Cookies require
credentials: include+Allow-Credentials: true+ a concrete origin. - Let OPTIONS through without auth. Cache preflight via
Max-Age. - 200 in Network + error in JS usually means missing CORS headers. Reproduce in curl to confirm.