CORS, One Request at a Time
A script on app.b.com asks api.a.com for an account. Follow that one request out of the browser and back: where it goes, whether it reaches the server, and whether the script is allowed to read the answer.
What is an origin?
Every page comes from somewhere. That somewhere is its origin.
Here is the address of the dashboard you're looking at. The browser keeps only its first three parts, which say how the page was fetched, from which machine and on which port, and calls them the page's origin. Everything after them only says where on that site you are.
THE ORIGINhttps://app.b.com scheme + host + port
NOT PART OF ITpath, query and fragment
:443, which is why it is drawn faded. https uses port 443 unless the URL says otherwise, and http uses 80, so the browser fills it in before comparing.Is this URL the same origin as the page?
Two URLs have the same origin only when all three parts match. Pick an example or type your own.
DIFFERENT ORIGINDifferent origin: the host differs. Another host on another site. This is the API the rest of the page calls.
The browser reads it as https://api.a.com/account
A URL with no scheme or host borrows them from the page, so it is always the same origin.
The path, the ?query and the #fragment say where on the site you are. They are not part of the origin.
https uses port 443 when none is written, so writing :443 changes nothing.
Host names are not case-sensitive. The browser lowercases them before comparing.
http is another scheme, and it brings its own default port, 80. Two parts differ.
A sibling subdomain is another host, even inside the same company.
The parent domain is another host too. Origins don't nest.
Same machine name, another port. That is another origin.
Another host on another site. This is the API the rest of the page calls.
The page's script can read whatever comes back. Nobody else has to agree.
The request can still go out. The script only gets to read the answer if the server says this origin may. That permission is CORS.
Next: follow a request to another origin →
An origin is where a page comes from
Browsers use the origin as the line between one site's pages and another's. A script runs with the origin of the page that loaded it, and reading anything from another origin needs that origin's permission.
"Site" is a looser idea. app.b.com and api.b.com are the same site, b.com, but different origins. Cookies and their SameSite setting work with sites; CORS works with origins. URLs such as data: and file: get an opaque origin that matches nothing.
HTML standard: origins ↗URL standard: default ports ↗RFC 6454: The Web Origin Concept ↗
THE POINT TO KEEPAn origin is scheme + host + port. Same origin means all three match; anything else is a different origin, and reading from it needs permission.
Same origin. There is nothing to compare: the script reads the answer whatever its headers say.
The map shows Reading the response: Same origin. Each view below describes its own exchanges.
Reading the response
The request goes out either way. The answer decides whether the script may read it.
Start where nothing can go wrong: the dashboard asks its own origin for the account. Then point the same plain GET at api.a.com. The browser still sends it straight away, but now it adds an Origin header naming app.b.com, and when the answer comes back it looks for a header that grants app.b.com permission to read it.
Same origin
Same origin. There is nothing to compare: the script reads the answer whatever its headers say.
Scheme, host and port all match the page, so the browser has nothing to check. The script reads the status, the body and every header, whatever the server sends.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for app.b.com.
- 2
Scheme, host and port all match https://app.b.com. Same origin: no CORS rules apply. The GET goes out with the session cookie.
- 3
app.b.com answers 200 OK.
- 4
The browser hands the whole response to your script.
WHY IT WORKSSame origin: the browser needs no permission from the server.
Request
- GET /api/account HTTP/1.1
- Cookie: session=9f2c…
- HTTP/1.1 200 OK
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 | fetch |
curl -i https://app.b.com/api/account \
-H 'Origin: https://app.b.com'No Allow-Origin
GET answer
Origin: https://app.b.com✕Access-Control-Allow-Origin: (none)Fails: no permission at all
The API answers 200 with the data, but names no origin that may read it. The browser keeps the response and hands the script a TypeError. The server did the work and logged a 200 anyway.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out.
- 3
api.a.com answers 200 OK.
- 4
The browser checks the answer for permission and finds none that fits. Your script gets an error, not the 200.
FIRST THING TO CHANGEHave the server send Access-Control-Allow-Origin: https://app.b.com.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: (missing)✕
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 CORS error | fetch |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Allow-Origin: https://app.b.com
GET answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: https://app.b.comFits: names this origin exactly
The answer names app.b.com, which matches the Origin the browser sent, so the script gets the status, the body and the basic headers.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out.
- 3
api.a.com answers 200 OK.
- 4
The answer names this origin, so the browser hands the response to your script.
WHY IT WORKSThe answer carries a permission that fits this origin and this credentials mode.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: https://app.b.com✓
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 | fetch |
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Allow-Origin: *
GET answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: *Fits: * lets any origin read
* means any origin may read it. That works here because the request carries no cookies. The Credentials view shows where it stops working.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out.
- 3
api.a.com answers 200 OK.
- 4
The answer names this origin, so the browser hands the response to your script.
WHY IT WORKSThe answer carries a permission that fits this origin and this credentials mode.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: *✓
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 | fetch |
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Reading the response
Access-Control-Allow-Origin holds one origin or *, never a list. To allow several, check the Origin header against an allow-list, echo the match, and send Vary: Origin so caches keep the answers apart. And because the request already reached the server, CORS can't protect a server from requests; it protects the response from being read.
Fetch standard: the CORS protocol ↗Fetch standard: CORS check ↗MDN: Cross-Origin Resource Sharing ↗
THE POINT TO KEEPA cross-origin request can reach the server and succeed there while the script still gets nothing. CORS decides who may read, not who may send.
Preflight
Some requests make the browser ask first. A refusal means the real request never leaves.
Now the dashboard POSTs JSON with a bearer token. Neither Content-Type: application/json nor the Authorization header is on the browser's short list of safe request features, so before sending the POST it sends an OPTIONS request naming the method and headers it wants to use.
Allows both headers
OPTIONS answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: https://app.b.comFits: names this origin exactlyAccess-Control-Request-Headers: authorization,content-type✓Access-Control-Allow-Headers: Content-Type, AuthorizationFits
POST answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: https://app.b.comFits: names this origin exactly
The 204 allows app.b.com and lists Content-Type and Authorization. Only then does the browser send the POST, and the answer is checked again on the way back.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. This request has Content-Type: application/json and an Authorization header, which is not CORS-safelisted, so the browser asks first with OPTIONS.
- 3
The answer allows https://app.b.com, POST and authorization, content-type. The browser goes ahead.
- 4
The POST goes out.
- 5
api.a.com answers 200 OK.
- 6
The answer names this origin, so the browser hands the response to your script.
WHY IT WORKSThe answer carries a permission that fits this origin and this credentials mode.
Preflight
- OPTIONS /account HTTP/1.1
- Origin: https://app.b.com
- Access-Control-Request-Method: POST
- Access-Control-Request-Headers: authorization,content-type
- HTTP/1.1 204 No Content✓
- Access-Control-Allow-Origin: https://app.b.com✓
- Access-Control-Allow-Methods: GET, POST
- Access-Control-Allow-Headers: Content-Type, Authorization✓
- Access-Control-Max-Age: 600
Actual request
- POST /account HTTP/1.1
- Origin: https://app.b.com
- Content-Type: application/json
- Authorization: Bearer eyJhbGciOi…
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: https://app.b.com✓
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | OPTIONS | 204 | preflight |
| account | POST | 200 | fetch |
curl -i -X OPTIONS https://api.a.com/account \
-H 'Origin: https://app.b.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'Leaves out Authorization
OPTIONS answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: https://app.b.comFits: names this origin exactlyAccess-Control-Request-Headers: authorization,content-type✕Access-Control-Allow-Headers: Content-TypeFails: authorization not covered
The POST was never sent, so there is no answer to check.
The OPTIONS gets a 204, but Allow-Headers lists only Content-Type. The browser stops. The server's log shows one OPTIONS and no POST: nothing was saved.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. This request has Content-Type: application/json and an Authorization header, which is not CORS-safelisted, so the browser asks first with OPTIONS.
- 3
The preflight answer does not give permission, so the browser stops here. The POST is never sent.
FIRST THING TO CHANGEAdd Authorization to Access-Control-Allow-Headers.
Preflight
- OPTIONS /account HTTP/1.1
- Origin: https://app.b.com
- Access-Control-Request-Method: POST
- Access-Control-Request-Headers: authorization,content-type
- HTTP/1.1 204 No Content✓
- Access-Control-Allow-Origin: https://app.b.com✓
- Access-Control-Allow-Methods: GET, POST
- Access-Control-Allow-Headers: Content-Type✕
- Access-Control-Max-Age: 600
POST /account — not sent
| Name | Method | Status | Type |
|---|---|---|---|
| account | OPTIONS | 204 CORS error | preflight |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
curl -i -X OPTIONS https://api.a.com/account \
-H 'Origin: https://app.b.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'Not handled: 405
OPTIONS answer
a 2xx status✕HTTP 405 Method Not AllowedFails: a preflight must succeed before anything else is read
The POST was never sent, so there is no answer to check.
The router has no route for OPTIONS and answers 405. A preflight needs a 2xx, so the POST is never sent.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. This request has Content-Type: application/json and an Authorization header, which is not CORS-safelisted, so the browser asks first with OPTIONS.
- 3
The preflight answer does not give permission, so the browser stops here. The POST is never sent.
FIRST THING TO CHANGEMake the router or gateway answer OPTIONS for this path with 204 and the CORS headers.
Preflight
- OPTIONS /account HTTP/1.1
- Origin: https://app.b.com
- Access-Control-Request-Method: POST
- Access-Control-Request-Headers: authorization,content-type
- HTTP/1.1 405 Method Not Allowed✕
- Allow: GET, POST
POST /account — not sent
| Name | Method | Status | Type |
|---|---|---|---|
| account | OPTIONS | 405 CORS error | preflight |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
curl -i -X OPTIONS https://api.a.com/account \
-H 'Origin: https://app.b.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'Preflight
GET, HEAD and POST never need listing in Allow-Methods; PUT, PATCH and DELETE do. Allow-Headers: * covers most headers without credentials but, under the Fetch standard, never Authorization. Chromium enforces that; Firefox 154 still lets the wildcard cover it by default. Access-Control-Max-Age lets the browser reuse a preflight answer for a while, capped by each browser.
Fetch standard: CORS-preflight fetch ↗Fetch standard: CORS-safelisted request headers ↗Fetch standard: CORS-safelisted method ↗
THE POINT TO KEEPA failed preflight means the request was never sent. A JSON body or an Authorization header is enough to need one.
Credentials
Sending cookies raises the bar: the answer must name you and say so twice.
The dashboard needs the user's session on api.a.com, so it calls fetch with credentials: 'include' and the browser attaches the cookie. For an answer built from someone's cookie, the browser wants two things: Allow-Origin naming app.b.com exactly, and Allow-Credentials: true.
Allow-Origin: *
GET answer
Origin: https://app.b.com✕Access-Control-Allow-Origin: *Fails: * is refused when cookies are sentcredentials: 'include'✓Access-Control-Allow-Credentials: trueFits
* means anyone may read this, and a response built from a cookie is the opposite of public. With credentials the browser refuses the wildcard, even with Allow-Credentials: true.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out, with the cookie only if it is marked SameSite=None.
- 3
api.a.com answers 200 OK.
- 4
The browser checks the answer for permission and finds none that fits. Your script gets an error, not the 200.
FIRST THING TO CHANGEWith credentials, name the origin: Access-Control-Allow-Origin: https://app.b.com, plus Allow-Credentials: true.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- Cookie: session=9f2c…!
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: *✕
- Access-Control-Allow-Credentials: true✓
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 CORS error | fetch |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: 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'.
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Names app.b.com only
GET answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: https://app.b.comFits: names this origin exactlycredentials: 'include'✕Access-Control-Allow-Credentials: (none)Fails: must be exactly true
The origin matches, but Allow-Credentials: true is missing. The value has to be exactly true; leaving it out fails.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out, with the cookie only if it is marked SameSite=None.
- 3
api.a.com answers 200 OK.
- 4
The browser checks the answer for permission and finds none that fits. Your script gets an error, not the 200.
FIRST THING TO CHANGEAdd Access-Control-Allow-Credentials: true to the response.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- Cookie: session=9f2c…!
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: https://app.b.com✓
- Access-Control-Allow-Credentials: (missing)✕
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 CORS error | fetch |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Names app.b.com + Allow-Credentials
GET answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: https://app.b.comFits: names this origin exactlycredentials: 'include'✓Access-Control-Allow-Credentials: trueFits
Both conditions hold, so the script reads the user's account.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out, with the cookie only if it is marked SameSite=None.
- 3
api.a.com answers 200 OK.
- 4
The answer names this origin, so the browser hands the response to your script.
WHY IT WORKSThe answer carries a permission that fits this origin and this credentials mode.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- Cookie: session=9f2c…!
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: https://app.b.com✓
- Access-Control-Allow-Credentials: true✓
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 | fetch |
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Echoes any Origin
GET answer
Origin: https://evil.example✓Access-Control-Allow-Origin: https://evil.exampleFits: echoed back, whatever the origincredentials: 'include'✓Access-Control-Allow-Credentials: trueFits
Someone made the errors go away by copying any Origin into Allow-Origin. Now a script on evil.example sends the visitor's cookie and reads their account. Check Origin against an exact allow-list instead.
Inspect the exchange⌄
- 1
Your page at evil.example runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out, with the cookie only if it is marked SameSite=None.
- 3
api.a.com answers 200 OK.
- 4
The answer names this origin, so the browser hands the response to your script.
FIRST THING TO CHANGECheck Origin against an allow-list before echoing it. Reflecting any origin with credentials hands every site your users' data.
Request
- GET /account HTTP/1.1
- Origin: https://evil.example
- Cookie: session=9f2c…!
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: https://evil.example✓
- Access-Control-Allow-Credentials: true✓
- Vary: Origin
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 | fetch |
curl -i https://api.a.com/account \
-H 'Origin: https://evil.example'Credentials
app.b.com and api.a.com are different sites, so the cookie only goes if it was set with SameSite=None; Secure, and a browser that blocks third-party cookies may hold it back anyway. CORS can't change that; serving the API from the same site can. With credentials, * in Allow-Headers, Allow-Methods and Expose-Headers is read literally too.
Fetch standard: CORS protocol and credentials ↗IETF: Cookies (RFC 6265bis), SameSite ↗Fetch standard: CORS check ↗
THE POINT TO KEEPWith credentials the wildcard stops working: the answer must name the origin and add Allow-Credentials: true, and the name must come from an allow-list.
Find the failure
The console blames CORS. The Network tab says what actually went wrong.
Anything that comes back without the right headers shows up as a CORS error, so the console message rarely names the cause. Work through the exchange in order and stop at the first answer that isn't what you expected.
- Is there an OPTIONS row, and did it get a 2xx?
- Did the real request go out, and what status came back?
- Does the answer's Allow-Origin name this page's origin?
- Did a cache or proxy produce the answer?
Auth rejects OPTIONS
OPTIONS answer
a 2xx status✕HTTP 401 UnauthorizedFails: a preflight must succeed before anything else is read
The POST was never sent, so there is no answer to check.
Authentication runs before anything else and demands a token, but a preflight never carries cookies or Authorization. It gets 401, so the POST is never sent. Let OPTIONS through authentication, in the app, the gateway and any WAF.
Caught at question 1: Is there an OPTIONS row, and did it get a 2xx?
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. This request has Content-Type: application/json and an Authorization header, which is not CORS-safelisted, so the browser asks first with OPTIONS.
- 3
The preflight answer does not give permission, so the browser stops here. The POST is never sent.
FIRST THING TO CHANGELet OPTIONS through before authentication. A preflight never carries cookies or the Authorization header.
Preflight
- OPTIONS /account HTTP/1.1
- Origin: https://app.b.com
- Access-Control-Request-Method: POST
- Access-Control-Request-Headers: authorization,content-type
- HTTP/1.1 401 Unauthorized✕
- WWW-Authenticate: Bearer
POST /account — not sent
| Name | Method | Status | Type |
|---|---|---|---|
| account | OPTIONS | 401 CORS error | preflight |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
curl -i -X OPTIONS https://api.a.com/account \
-H 'Origin: https://app.b.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'A 500 without CORS headers
GET answer
Origin: https://app.b.com✕Access-Control-Allow-Origin: (none)Fails: the 500 Internal Server Error carries no CORS headers
The handler crashed, and the framework's error page adds no CORS headers. The real failure is the 500. Fix it, and add CORS headers in the outermost layer so errors are readable next time.
Caught at question 2: Did the real request go out, and what status came back?
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out.
- 3
api.a.com answers 500 Internal Server Error and, like many error and redirect paths, adds no CORS headers.
- 4
The browser checks the answer for permission and finds none that fits. Your script gets an error, not the 500.
FIRST THING TO CHANGEFind out why the server returned 500. Then add CORS headers to error responses so the next one is readable.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- HTTP/1.1 500 Internal Server Error✕
- Access-Control-Allow-Origin: (missing)✕
- Content-Type: text/html
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 500 CORS error | fetch |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Redirect to login
GET answer
Origin: https://app.b.com✕Access-Control-Allow-Origin: (none)Fails: the 302 Found carries no CORS headers
The session expired and the API redirected to login.a.com, as it would for a page. The 302 has no CORS headers, so the browser stops there. An API should answer 401 and let the page decide.
Caught at question 2: Did the real request go out, and what status came back?
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out.
- 3
api.a.com answers 302 Found and, like many error and redirect paths, adds no CORS headers.
- 4
The browser checks the answer for permission and finds none that fits. Your script gets an error, not the 302.
FIRST THING TO CHANGEAnswer an expired session with 401 and CORS headers, and let the page send the user to log in.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- HTTP/1.1 302 Found✕
- Access-Control-Allow-Origin: (missing)✕
- Location: https://login.a.com/?next=/account
- Content-Type: text/html
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 302 CORS error | fetch |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'CDN cached another origin's answer
GET answer
Origin: https://app.b.com✕Access-Control-Allow-Origin: https://admin.c.comFails: a cached copy made for admin.c.com
The edge's cache key ignores Origin, so it serves a copy made for admin.c.com, and the API never sees the request. Put Origin in the cache key, or set the CORS headers at the edge.
Caught at question 4: Did a cache or proxy produce the answer?
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out.
- 3
The CDN answers from its cache. That copy was stored for another origin and still names it.
- 4
The browser checks the answer for permission and finds none that fits. Your script gets an error, not the 200.
FIRST THING TO CHANGEMake the CDN cache per Origin (add Origin to the cache key) or set the CORS headers at the edge.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: https://admin.c.com✕
- Vary: Origin
- X-Cache: Hit from cloudfront✕
- Age: 212
- Content-Type: application/json
- X-Request-Id: 7f3a91
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 CORS error | fetch |
CONSOLEAccess to fetch at 'https://api.a.com/account' from origin 'https://app.b.com' has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header has a value 'https://admin.c.com' that is not equal to the supplied origin.
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Find the failure
Each response in a redirect chain needs its own permission, and after a redirect to yet another origin the browser sends Origin: null. Vary: Origin informs caches that honour it; in CloudFront what counts is the cache policy or a response headers policy. curl shows the headers a server returns, but it doesn't enforce CORS, so a working curl proves little.
MDN: CORS errors ↗Fetch standard: HTTP-redirect fetch ↗AWS: CloudFront caching based on request headers ↗AWS: CloudFront response headers policies ↗
THE POINT TO KEEPFind the first exchange that really failed before changing CORS headers. The console's CORS error is often only the messenger.
Common traps
Loading, sending and reading are three different things.
CORS guards what a page's script can read. Plenty still crosses origins without asking, and some of what looks like a fix only hides the answer.
mode: 'no-cors'
No permission was asked for, so none is compared. The page gets an opaque response it can use but not read.
The request goes and the console stays quiet, but the script gets an opaque response: status 0, no headers, no body. It is never a fix for an API you need to read, and it drops headers like Authorization.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
Cross-origin, but mode: 'no-cors' asks for no permission. The browser will hand the page an opaque response. The GET goes out.
- 3
api.a.com answers 200 OK.
- 4
The browser hands back a sealed response: it exists, and that is all the script learns.
FIRST THING TO CHANGEDrop mode: 'no-cors' and have the server send CORS headers. no-cors never makes a response readable.
Request
- GET /account HTTP/1.1
- HTTP/1.1 200 OK
- Content-Type: application/json
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 | fetch |
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'An <img> from another origin
No permission was asked for, so none is compared. The page gets an opaque response it can use but not read.
The image loads and shows without permission, but script can't read its pixels: a canvas it is drawn into becomes tainted. With the crossorigin attribute and an Allow-Origin header, it can.
Inspect the exchange⌄
- 1
Your page at app.b.com runs an <img> element for api.a.com.
- 2
Cross-origin, but an <img> without crossorigin asks for no permission. The browser will hand the page an opaque response. The GET goes out, with the cookie only if it is marked SameSite=None.
- 3
api.a.com answers 200 OK.
- 4
The image is displayed. The page can show it but its script cannot read the bytes.
FIRST THING TO CHANGETo read the pixels, add crossorigin to the <img> and have the server send Access-Control-Allow-Origin.
Request
- GET /logo.png HTTP/1.1
- Cookie: session=9f2c…!
- HTTP/1.1 200 OK
- Content-Type: image/png
| Name | Method | Status | Type |
|---|---|---|---|
| logo.png | GET | 200 | png |
curl -i https://api.a.com/logo.png \
-H 'Origin: https://app.b.com'A form post from another site
A form post is a navigation. The page never reads the answer, so nothing is compared.
A page on evil.example submits a form to api.a.com. It is a navigation, so there is no preflight and no check, and if the cookie goes, the email changes. That is CSRF: defend with a token, SameSite cookies or Sec-Fetch-Site.
Inspect the exchange⌄
- 1
Your page at evil.example runs a form submission for api.a.com.
- 2
A form submission is a navigation, not a read by the page. It needs no permission and gets no preflight. The POST goes out, with the cookie only if it is marked SameSite=None.
- 3
api.a.com answers 200 OK. The tab navigates to api.a.com's answer. The request did its work on the server; CORS never came into it.
FIRST THING TO CHANGEDefend the endpoint itself: a CSRF token, SameSite=Lax or Strict cookies, or rejecting cross-site Sec-Fetch-Site.
Request
- POST /account/email HTTP/1.1
- Origin: https://evil.example
- Content-Type: application/x-www-form-urlencoded
- Cookie: session=9f2c…!
- HTTP/1.1 200 OK
- Content-Type: application/json
| Name | Method | Status | Type |
|---|---|---|---|
| POST | 200 | document |
curl -i https://api.a.com/account/email \
-H 'Origin: https://evil.example'A custom header reads null
GET answer
Origin: https://app.b.com✓Access-Control-Allow-Origin: https://app.b.comFits: names this origin exactlyheaders.get('X-Request-Id')✕Access-Control-Expose-Headers: (none)Fails: only that header is hidden; the rest is shared
The response is shared and the JSON reads fine, but X-Request-Id comes back null. Scripts only see basic response headers unless the server lists others in Access-Control-Expose-Headers.
Inspect the exchange⌄
- 1
Your page at app.b.com runs fetch() for api.a.com.
- 2
https://api.a.com is a different origin, so this is a CORS request. The browser adds an Origin header. GET is CORS-safelisted, so there is no preflight. The request goes straight out. The GET goes out.
- 3
api.a.com answers 200 OK.
- 4
The browser shares the response, but hides X-Request-Id: it is not in Access-Control-Expose-Headers.
FIRST THING TO CHANGEAdd Access-Control-Expose-Headers: X-Request-Id to the response.
Request
- GET /account HTTP/1.1
- Origin: https://app.b.com
- HTTP/1.1 200 OK
- Access-Control-Allow-Origin: https://app.b.com✓
- Content-Type: application/json
- X-Request-Id: 7f3a91!
| Name | Method | Status | Type |
|---|---|---|---|
| account | GET | 200 | fetch |
curl -i https://api.a.com/account \
-H 'Origin: https://app.b.com'Common traps
Allow-Headers is about what may go to the server; Expose-Headers is about what the script may read from the answer. Module scripts and web fonts are always fetched in CORS mode, so they need permission like fetch() does.
Fetch standard: opaque filtered response ↗HTML standard: security with canvas elements ↗HTML standard: CORS settings attributes ↗MDN: Cross-site request forgery ↗W3C: Fetch Metadata request headers ↗Fetch standard: CORS-safelisted response header names ↗
THE POINT TO KEEPLoading and sending across origins is allowed by default. Reading the answer is the part that needs permission.