Infra, Illustrated.
BROWSER SECURITY · CORS

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.

EXAMPLE · app.b.com → api.a.comFOUNDATIONAL · FLOW EXPLORERPublished Oct 14, 2026 · Reviewed Oct 14, 2026
VIEWOrigins
app.b.com → api.a.com
01 · BASICS

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.

httpsscheme://app.b.comhost:443port/dashboardpath?tab=2query#topfragment

THE ORIGINhttps://app.b.com scheme + host + port

NOT PART OF ITpath, query and fragment

Nobody typed :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.

this pagethe URL
schemehttpshttps= same
hostapp.b.comapi.a.com≠ differs
port443443 default= same

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.

SAME ORIGIN

The page's script can read whatever comes back. Nobody else has to agree.

DIFFERENT ORIGIN

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 →

BASICS

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.

WORTH KNOWING

"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.

Did the request reach the server?Yes
Can the script read the response?Yes
One request's trip from the page to the APIThe page's script, inside the browser, asks for a URL. The request passes the browser's read check on the way out, sometimes after a preflight, crosses the network, maybe through a CDN, and reaches the server. The answer comes back to the read check, which hands it to the script or holds it back. The two answers beside the map say whether the request reached the server and whether the script can read the response.One request's trip from the page to the APIThe page's script, inside the browser, asks for a URL. The request passes the browser's read check on the way out, sometimes after a preflight, crosses the network, maybe through a CDN, and reaches the server. The answer comes back to the read check, which hands it to the script or holds it back. The two answers beside the map say whether the request reached the server and whether the script can read the response.
sent handed to the script
AT THE READ CHECKthe request carriedthe answer granted

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.

02 · BASICS

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

Did the request reach the server?Yes
Can the script read the response?Yes
AT THE READ CHECKthe request carriedthe answer granted

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. 1

    Your page at app.b.com runs fetch() for app.b.com.

  2. 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. 3

    app.b.com answers 200 OK.

  4. 4

    The browser hands the whole response to your script.

WHY IT WORKSSame origin: the browser needs no permission from the server.

Request

BROWSER SENT
  • GET /api/account HTTP/1.1
  • Cookie: session=9f2c…
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Content-Type: application/json
  • X-Request-Id: 7f3a91
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://app.b.com/api/account \
  -H 'Origin: https://app.b.com'

No Allow-Origin

Did the request reach the server?Yes
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Access-Control-Allow-Origin: (missing)✕
  • Content-Type: application/json
  • X-Request-Id: 7f3a91
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200 CORS errorfetch

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.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

Allow-Origin: https://app.b.com

Did the request reach the server?Yes
Can the script read the response?Yes
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Access-Control-Allow-Origin: https://app.b.com✓
  • Content-Type: application/json
  • X-Request-Id: 7f3a91
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

Allow-Origin: *

Did the request reach the server?Yes
Can the script read the response?Yes
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Access-Control-Allow-Origin: *✓
  • Content-Type: application/json
  • X-Request-Id: 7f3a91
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'
BASICS

Reading the response

WORTH KNOWING

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.

03 · REQUESTS

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

Did the request reach the server?Yes
Can the script read the response?Yes
AT THE READ CHECKthe request carriedthe answer granted

OPTIONS answer

  • Origin: https://app.b.comAccess-Control-Allow-Origin: https://app.b.comFits: names this origin exactly
  • Access-Control-Request-Headers: authorization,content-typeAccess-Control-Allow-Headers: Content-Type, AuthorizationFits

POST answer

  • Origin: https://app.b.comAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    The answer allows https://app.b.com, POST and authorization, content-type. The browser goes ahead.

  4. 4

    The POST goes out.

  5. 5

    api.a.com answers 200 OK.

  6. 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

BROWSER SENT
  • OPTIONS /account HTTP/1.1
  • Origin: https://app.b.com
  • Access-Control-Request-Method: POST
  • Access-Control-Request-Headers: authorization,content-type
SERVER ANSWERED
  • 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

BROWSER SENT
  • POST /account HTTP/1.1
  • Origin: https://app.b.com
  • Content-Type: application/json
  • Authorization: Bearer eyJhbGciOi…
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Access-Control-Allow-Origin: https://app.b.com✓
  • Content-Type: application/json
  • X-Request-Id: 7f3a91
DEVTOOLS · NETWORK
NameMethodStatusType
accountOPTIONS204preflight
accountPOST200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
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

Did the request reach the server?No: only the OPTIONS
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

OPTIONS answer

  • Origin: https://app.b.comAccess-Control-Allow-Origin: https://app.b.comFits: names this origin exactly
  • Access-Control-Request-Headers: authorization,content-typeAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 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

BROWSER SENT
  • OPTIONS /account HTTP/1.1
  • Origin: https://app.b.com
  • Access-Control-Request-Method: POST
  • Access-Control-Request-Headers: authorization,content-type
SERVER ANSWERED
  • 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

DEVTOOLS · NETWORK
NameMethodStatusType
accountOPTIONS204 CORS errorpreflight

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.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
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

Did the request reach the server?No: only the OPTIONS
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

OPTIONS answer

  • a 2xx statusHTTP 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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 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

BROWSER SENT
  • OPTIONS /account HTTP/1.1
  • Origin: https://app.b.com
  • Access-Control-Request-Method: POST
  • Access-Control-Request-Headers: authorization,content-type
SERVER ANSWERED
  • HTTP/1.1 405 Method Not Allowed✕
  • Allow: GET, POST

POST /account — not sent

DEVTOOLS · NETWORK
NameMethodStatusType
accountOPTIONS405 CORS errorpreflight

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.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
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'
REQUESTS

Preflight

WORTH KNOWING

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.

04 · REQUESTS

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: *

Did the request reach the server?Yes
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-Control-Allow-Origin: *Fails: * is refused when cookies are sent
  • credentials: '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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
  • Cookie: session=9f2c…!
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Access-Control-Allow-Origin: *✕
  • Access-Control-Allow-Credentials: true✓
  • Content-Type: application/json
  • X-Request-Id: 7f3a91
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200 CORS errorfetch

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'.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

Names app.b.com only

Did the request reach the server?Yes
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-Control-Allow-Origin: https://app.b.comFits: names this origin exactly
  • credentials: '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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
  • Cookie: session=9f2c…!
SERVER ANSWERED
  • 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
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200 CORS errorfetch

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'.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

Names app.b.com + Allow-Credentials

Did the request reach the server?Yes
Can the script read the response?Yes
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-Control-Allow-Origin: https://app.b.comFits: names this origin exactly
  • credentials: 'include'Access-Control-Allow-Credentials: trueFits

Both conditions hold, so the script reads the user's account.

Inspect the exchange
  1. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
  • Cookie: session=9f2c…!
SERVER ANSWERED
  • 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
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

Echoes any Origin

Did the request reach the server?Yes
Can the script read the response?Yes
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://evil.exampleAccess-Control-Allow-Origin: https://evil.exampleFits: echoed back, whatever the origin
  • credentials: '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. 1

    Your page at evil.example runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://evil.example
  • Cookie: session=9f2c…!
SERVER ANSWERED
  • 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
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://evil.example'
REQUESTS

Credentials

WORTH KNOWING

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.

05 · TROUBLESHOOTING

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.

  1. Is there an OPTIONS row, and did it get a 2xx?
  2. Did the real request go out, and what status came back?
  3. Does the answer's Allow-Origin name this page's origin?
  4. Did a cache or proxy produce the answer?

Auth rejects OPTIONS

Did the request reach the server?No: only the OPTIONS
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

OPTIONS answer

  • a 2xx statusHTTP 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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 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

BROWSER SENT
  • OPTIONS /account HTTP/1.1
  • Origin: https://app.b.com
  • Access-Control-Request-Method: POST
  • Access-Control-Request-Headers: authorization,content-type
SERVER ANSWERED
  • HTTP/1.1 401 Unauthorized✕
  • WWW-Authenticate: Bearer

POST /account — not sent

DEVTOOLS · NETWORK
NameMethodStatusType
accountOPTIONS401 CORS errorpreflight

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.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
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

Did the request reach the server?Yes
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 500 Internal Server Error and, like many error and redirect paths, adds no CORS headers.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
SERVER ANSWERED
  • HTTP/1.1 500 Internal Server Error✕
  • Access-Control-Allow-Origin: (missing)✕
  • Content-Type: text/html
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET500 CORS errorfetch

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.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

Redirect to login

Did the request reach the server?Yes
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 302 Found and, like many error and redirect paths, adds no CORS headers.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
SERVER ANSWERED
  • HTTP/1.1 302 Found✕
  • Access-Control-Allow-Origin: (missing)✕
  • Location: https://login.a.com/?next=/account
  • Content-Type: text/html
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET302 CORS errorfetch

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.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

CDN cached another origin's answer

Did the request reach the server?No: the CDN answered
Can the script read the response?No
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    The CDN answers from its cache. That copy was stored for another origin and still names it.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
SERVER ANSWERED
  • 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
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200 CORS errorfetch

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.

HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'
TROUBLESHOOTING

Find the failure

WORTH KNOWING

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.

06 · TROUBLESHOOTING

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'

Did the request reach the server?Yes
Can the script read the response?It can use it, not read it
AT THE READ CHECKthe request carriedthe answer granted

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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Content-Type: application/json
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'

An <img> from another origin

Did the request reach the server?Yes
Can the script read the response?It can use it, not read it
AT THE READ CHECKthe request carriedthe answer granted

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. 1

    Your page at app.b.com runs an <img> element for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /logo.png HTTP/1.1
  • Cookie: session=9f2c…!
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Content-Type: image/png
DEVTOOLS · NETWORK
NameMethodStatusType
logo.pngGET200png
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/logo.png \
  -H 'Origin: https://app.b.com'

A form post from another site

Did the request reach the server?Yes
Can the script read the response?Nothing to read: the tab navigates
AT THE READ CHECKthe request carriedthe answer granted

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. 1

    Your page at evil.example runs a form submission for api.a.com.

  2. 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. 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

BROWSER SENT
  • POST /account/email HTTP/1.1
  • Origin: https://evil.example
  • Content-Type: application/x-www-form-urlencoded
  • Cookie: session=9f2c…!
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Content-Type: application/json
DEVTOOLS · NETWORK
NameMethodStatusType
emailPOST200document
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account/email \
  -H 'Origin: https://evil.example'

A custom header reads null

Did the request reach the server?Yes
Can the script read the response?Yes, but not X-Request-Id
AT THE READ CHECKthe request carriedthe answer granted

GET answer

  • Origin: https://app.b.comAccess-Control-Allow-Origin: https://app.b.comFits: names this origin exactly
  • headers.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. 1

    Your page at app.b.com runs fetch() for api.a.com.

  2. 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. 3

    api.a.com answers 200 OK.

  4. 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

BROWSER SENT
  • GET /account HTTP/1.1
  • Origin: https://app.b.com
SERVER ANSWERED
  • HTTP/1.1 200 OK
  • Access-Control-Allow-Origin: https://app.b.com✓
  • Content-Type: application/json
  • X-Request-Id: 7f3a91!
DEVTOOLS · NETWORK
NameMethodStatusType
accountGET200fetch
HEADER CHECK WITH CURL · curl shows the headers; it does not enforce CORS
curl -i https://api.a.com/account \
  -H 'Origin: https://app.b.com'
TROUBLESHOOTING

Common traps

WORTH KNOWING

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.