Cross-Origin Resource Sharing (CORS)
Cross-Origin Resource Sharing (CORS) is how a server tells the browser which other origins may read its responses. An origin is the scheme, host and port of a URL, parts you split in the lesson on URLs, path and query parameters. Under the same-origin policy, a script on https://shop.example.com can send a request to https://api.example.com but cannot read the response unless the API allows it with CORS headers.
Example: the shop page calls fetch('https://api.example.com/v1/cart') and the browser adds Origin: https://shop.example.com. If the response carries Access-Control-Allow-Origin: https://shop.example.com, the page gets the cart; otherwise its code gets only a generic network error and the Console reports a CORS error.
Why it matters
When the front end and the API live on different origins, a missing or wrong CORS header breaks real features: the cart does not load, a save button does nothing, an error message never appears. Often only one environment fails, because each has its own domains and gateway settings.
CORS also protects data: a server that lets any origin read responses sent with the user's cookies can let other websites the user visits read that data. For a tester, CORS explains many "works in Postman, fails in the browser" reports.
Origins and the same-origin policy
Two URLs share an origin only when scheme, host and port are all identical; the path and the query do not matter. A subdomain is a different host and http is a different scheme from https, so the shop and its API are two origins even though one company runs both.
The same-origin policy is mainly about reading, not sending: links, forms, images and scripts from other origins work as always. What it stops is a script reading a cross-origin response. CORS relaxes that rule on the server's terms: the server sends headers, the browser enforces them, and clients outside a browser, such as curl or Postman, ignore them.
Page origin: https://shop.example.com
URL Same origin? Why
https://shop.example.com/cart?id=7 yes only path and query differ
https://shop.example.com:443/help yes 443 is the default https port
http://shop.example.com/cart no scheme differs
https://api.example.com/v1/cart no host differs (subdomain)
https://shop.example.com:8443/cart no port differsSimple and preflighted requests
A GET, HEAD or POST with only CORS-safelisted headers, such as Accept or a Content-Type of text/plain, multipart/form-data or application/x-www-form-urlencoded, is sent at once with an Origin header. MDN calls it a simple request; the Fetch Standard does not use that name. The server processes it whatever its CORS settings; CORS only decides whether the page may read the response.
Any other cross-origin request is preflighted: PUT, PATCH, DELETE, a POST with Content-Type: application/json, or a header such as Authorization. The browser first sends an OPTIONS request naming the method and headers it wants to use. Only if the preflight gets a 2xx response whose headers permit the origin, method and headers is the actual request sent, and its response is checked again. A preflight answered with a redirect or an error status fails, and the actual request never leaves the browser.
Credentials (cookies or HTTP authentication, sent cross-origin only when the script asks, with credentials: 'include' in fetch or withCredentials = true in XMLHttpRequest) tighten the rules: both responses must name the exact origin, never *, and send Access-Control-Allow-Credentials: true; a * in the allowed methods or headers no longer works as a wildcard either. The preflight itself never carries cookies.
Simple request: no preflight
- Shop page
https://shop.example.com - Browser
- API
api.example.com
- 1 From Shop page To Browser: Reads the cart
GET /v1/cart - 2 From Browser To API: Sends it at once, with
OriginOrigin: https://shop.example.com - 3 From API To Browser: Processes it and responds reply Whatever its CORS settings
- 4 Browser internally: Checks
Access-Control-Allow-OriginThe page origin, or*when no cookies are sent - 5 From Browser To Shop page: The data, or a generic network error reply
Outcome: The server handles the request either way; CORS only decides whether the page may read the answer.
- Shop page
Preflighted request: PUT with cookies
- Shop page
https://shop.example.com - Browser
- API
api.example.com
- 1 From Shop page To Browser: Updates the cart JSON body,
credentials: 'include'PUT /v1/cart/items/42 - 2 From Browser To API: Preflight, without cookies Names the method and headers it wants to use
OPTIONS /v1/cart/items/42 - 3 From API To Browser: 2xx with
Access-Control-Allow-*reply - 4 Browser internally: Checks origin, method, headers, credentials A redirect, an error status or any mismatch stops here
- 5 From Browser To API: Actual request, with cookies
PUT /v1/cart/items/42 - 6 From API To Browser: Response naming the exact origin reply And
Access-Control-Allow-Credentials: true - 7 Browser internally: Checks the response again
- 8 From Browser To Shop page: The data, or a generic network error reply
Outcome: If the preflight check fails, the
PUTis never sent.- Shop page
While a cached preflight result is valid (Access-Control-Max-Age), the browser skips steps 2 to 4 of the second panel.
Origin(set by the browser), plusAccess-Control-Request-MethodandAccess-Control-Request-Headersin the preflight: who asks and what the actual request will use.Access-Control-Allow-Origin: the one origin allowed to read, or*when no credentials are involved.Access-Control-Allow-Methods,Access-Control-Allow-Headers: what the preflight response permits.Access-Control-Allow-Credentials: true: required when credentials are sent.Access-Control-Expose-Headers: extra response headers a script may read, such asX-Total-Count.Access-Control-Max-Age: seconds a preflight result may be reused, within each browser's own limit.Vary: Origin: tells caches the response differs by origin; needed when the server echoes the origin.
Common misconfigurations
Settings that are too strict or inconsistent block the real front end and break features; settings that are too loose let other websites read data through a signed-in user's browser, which is why the OWASP Web Security Testing Guide includes CORS. How far another website really gets also depends on the cookies' SameSite attribute and the browser's third-party cookie settings (see the lesson on cookies and sessions), but the tester reports the over-permissive policy itself.
- Breaks features: authentication on
OPTIONS, so the cookie-less preflight gets401or403. - Breaks features: a redirect on the preflight, such as adding a trailing slash (see the lesson on HTTP redirects).
- Breaks features: CORS headers on success responses only, so a
400or500reaches the page as a generic network error. - Breaks features:
Access-Control-Allow-Originsent twice (application and gateway) or as a list; it must hold one value. - Exposes data: echoing any
OriginwithAccess-Control-Allow-Credentials: true, or allowingOrigin: null, which any website can produce from a sandboxed frame. - Exposes data: matching origins by prefix, suffix or an unanchored pattern, so
https://shop.example.com.attacker.testorhttps://notexample.compasses.
How to test CORS
Start in a real browser, because only the browser enforces CORS. In the DevTools Network panel a preflight appears as its own OPTIONS entry just before the actual request (clear a Fetch/XHR filter if it is hidden). The Console says why a response was blocked; the page's code gets only a generic error, in Chrome TypeError: Failed to fetch.
Then replay the requests with curl or the CORS Tester, set any Origin you need and apply the browser's checks yourself, only on systems you own or have written permission to test. Take the test conditions from the real callers: each front-end origin in each environment, each method and header, with and without cookies.
- Allowed origin: a 2xx preflight with the right
Allow-*headers, andAccess-Control-Allow-Originon the actual response too. - Unlisted origins such as
https://attacker.testandnull, and variants of an allowed origin:http://instead ofhttps://, another port, look-alike hosts such ashttps://shop.example.com.attacker.test. None may be echoed. - Every method and header the front end really sends, not only
GET. - Error responses (
401,404,500) with the same CORS headers as successes. - With credentials: no
*, andVary: Originwhenever the origin is echoed.
curl -i -X OPTIONS https://api.example.com/v1/cart/items/42 \
-H "Origin: https://shop.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type"Try it: CORS Tester Try it: cURL Generator Try it: HTTP Headers Parser
Worked example: a cart update blocked in production
In staging, changing a cart quantity works; in production nothing happens and the Console shows a CORS error. The front end at https://shop.example.com sends PUT /v1/cart/items/42 to https://api.example.com with a JSON body and cookies, so the browser preflights it. The tester replays the preflight with curl.
Status, origin and credentials are right, but Access-Control-Allow-Methods lacks PUT, so the browser stops after the preflight and the PUT never reaches the API; no server log shows a failed update. Staging works because its gateway is configured separately.
Repeating the preflight and a GET /v1/cart with Origin: https://attacker.test and with another unrelated origin gets both origins echoed with Access-Control-Allow-Credentials: true: a separate security defect, reported privately, because other websites could then read a signed-in customer's data.
OPTIONS /v1/cart/items/42 HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Credentials: true
Vary: OriginCommon mistakes
- Assuming a CORS error means the request never reached the server: a simple request was sent and processed, so a
POSTmay already have changed data, while after a failed preflight the actual request was never sent. - Calling a fix verified because curl or Postman now works. They ignore CORS; the final check belongs in the browser.
- Testing only the allowed origin and never an unlisted, look-alike or
nullorigin. - Testing only
GETand the success path, so a missingPUTor missing headers on500responses go unnoticed. - Asking for
Access-Control-Allow-Origin: *to unblock testing: it fails with cookies and opens the API to every website. - Retesting a tightened rule too soon: the browser may reuse a cached, more permissive preflight result until
Access-Control-Max-Ageruns out.
The QA perspective
On a team, CORS belongs in API reviews and environment checklists. Ask early which origins call the API in each environment, whether any call sends cookies, which methods and custom headers the front end uses, and who owns the configuration: the application, the gateway or the CDN.
When you report a CORS defect, attach the preflight and the actual request with their headers, the exact Origin, the browser and the Console message, and name the expected value ("Access-Control-Allow-Methods should include PUT"). Report an over-permissive policy privately as a security finding, and keep an unlisted-origin check in the regression suite after the fix.
Practice exercise
Review three preflight responses
A front end at https://portal.example.com calls DELETE /v1/sessions/current on https://api.example.com with cookies (credentials: 'include') and the header X-CSRF-Token. Below are the API's answers to three preflights for this call. For each, decide whether the browser sends the DELETE, and name every problem.
# A request Origin: https://portal.example.com
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: X-CSRF-Token
Access-Control-Allow-Credentials: true
# B request Origin: https://portal.example.com
HTTP/1.1 401 Unauthorized
Access-Control-Allow-Origin: https://portal.example.com
Access-Control-Allow-Credentials: true
# C request Origin: https://evil.test
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://evil.test
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: X-CSRF-Token
Access-Control-Allow-Credentials: true
Vary: Origin- Status: is it 2xx?
- Compare
Access-Control-Allow-Originwith the request origin under the credentials rule. - Are
DELETEandX-CSRF-Tokenallowed? - Is the
DELETEsent? Classify each problem as functional or security.
Show the model answer
A: blocked, a functional defect. The call sends cookies, and with credentials Access-Control-Allow-Origin must be the exact origin, not *. Fix: return https://portal.example.com, with Vary: Origin if the value is chosen per request.
B: blocked, a functional defect. A preflight needs a 2xx; the 401 shows that authentication runs before OPTIONS is answered, yet a preflight never carries cookies. DELETE and X-CSRF-Token are not allowed either. Fix: answer preflights before authentication, with the full Allow-* headers.
C: the browser lets it through, and that is the problem: the API accepts, with credentials, an origin that is not one of its front ends, so a page on https://evil.test could send requests with a signed-in user's cookies and read the responses. It is a security defect; confirm it with another unrelated origin and report it privately.
Frequently asked questions
Does CORS protect my API from attackers?
No. CORS controls what a browser lets a web page read; it does not authenticate anyone. curl, scripts and other servers can call the API with any Origin header they like, so the API still needs authentication and authorisation. It is not a CSRF defence either: simple requests reach the server before any CORS check.
Why does the request work in Postman but fail in the browser?
Postman and curl are not browsers and do not apply the same-origin policy, so they never send a preflight or check Access-Control-* headers. The browser does both. Look at the browser's preflight in the Network panel and compare it with what the server really returns for an OPTIONS request with the same Origin.
Is Access-Control-Allow-Origin: * always wrong?
No. For public data that needs no cookies, such as a public catalogue, * is acceptable and simple. It is wrong for responses that depend on the user's cookies (browsers reject * with credentials anyway) and risky for internal APIs that trust their network instead of authenticating callers.
References
- Fetch StandardDefines CORS: preflight requests, the Access-Control-* headers and which responses a browser may expose to a page.
- Cross-Origin Resource Sharing (CORS) — MDNHow CORS works in practice: simple vs. preflighted requests, credentials, and the errors browsers report.
- OWASP Web Security Testing Guide (WSTG)The reference methodology for testing web applications and APIs for security weaknesses, test by test.
- HTML Living StandardThe HTML language, including named character references (entities) and the parsing rules browsers follow.
Practise with Mutqan tools
Test CORS configuration: send preflight and simple requests from a given origin and inspect the Access-Control-* headers.
Build cURL commands from method, URL, headers, body and auth options.
Parse raw HTTP request/response headers into a structured table with explanations.
Compose HTTP headers with presets for caching, CORS, security and content negotiation.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.