Cross-Origin Resource Sharing lets a server opt into exposing a response to scripts from another origin. An origin consists of scheme, host, and port; two services on different ports can therefore be cross-origin even on the same machine. The browser evaluates the CORS response headers for the requesting page. This mechanism does not authenticate that page’s user or turn the endpoint into a private network service. Same-site cookie rules use a different boundary from same-origin response access, so similar-looking hostnames are insufficient to reason about both.
For an allowed page, the API can return Access-Control-Allow-Origin with that page’s serialized origin. The browser then considers the other relevant CORS conditions before exposing the response. A wildcard is appropriate for some resources intended for uncredentialed cross-origin reading, but cannot authorize a request whose credentials mode is include. A server should select allowed origins deliberately instead of reflecting arbitrary incoming values as permission.
Separate delivery from response access
Some requests, conventionally called simple requests, do not need a CORS preflight. A POST using text/plain and only safelisted request headers can qualify. “Safelisted” describes the browser protocol; it does not mean the endpoint is read-only or that the request cannot change data. HTML forms have long been able to send comparable cross-origin requests without first obtaining permission to read the response.
Consequently, the browser can send a request, receive an HTTP 200 response, and still reject the script’s fetch because response exposure was not permitted. The server may already have performed the operation. Non-browser HTTP clients do not enforce this page-origin rule at all. Authentication, authorization, and protection against unwanted cross-site actions therefore need their own server-side design, independently of a response’s CORS headers.
When it breaks
A common incident begins with “the browser blocked it, so nothing happened.” The user retries a state-changing request and creates a duplicate because the first operation actually succeeded. Inspect server evidence before retrying, and make operation-level retry semantics explicit. For cookie-authenticated state changes, apply the appropriate CSRF protections; withholding Access-Control-Allow-Origin alone is not a substitute for validating whether the action should be accepted.
Another mistake is treating every fetch TypeError as proof of a CORS configuration problem. Network failures can also make fetch reject. Compare the browser’s diagnostic messages, the delivered response headers, and the server request log. Switching to no-cors does not give application code an ordinary readable response; its opaque response restrictions prevent that shortcut from solving an API that needs to consume response data.
Check it quickly
Save this as cors-simple.py, run python3 cors-simple.py --serve, and open the printed loopback page URL. Python 3.14.4 and Chrome 152 were used for this check. The script starts a page origin and an API origin on different temporary ports. The only side effect is appending synthetic events to an in-memory list. Press Control-C to close both servers; no account, cookie, or production API is involved.
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.request import Request, urlopen
events = []
class API(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"])).decode()
events.append({"method": "POST", "path": self.path, "body": body})
payload = b"synthetic action recorded"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(payload)))
if self.path == "/allowed":
self.send_header("Access-Control-Allow-Origin", app_origin)
self.send_header("Vary", "Origin")
self.end_headers()
self.wfile.write(payload)
def do_OPTIONS(self):
events.append({"method": "OPTIONS", "path": self.path})
self.send_response(403)
self.send_header("Content-Length", "0")
self.end_headers()
class App(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/events":
body = json.dumps(events).encode()
kind = "application/json"
else:
body = ("""<!doctype html><meta charset="utf-8"><title>CORS simple request lab</title>
<h1>Read permission and request delivery</h1><pre id="result">Running...</pre><script>
const api=""" + json.dumps(api_origin) + """;
async function run() {
const results={};
for(const path of ['allowed','blocked']) {
try {
const response=await fetch(api+'/'+path,{method:'POST',headers:{'Content-Type':'text/plain'},body:'synthetic-change'});
results[path]={readable:true,status:response.status,body:await response.text()};
} catch(error) {results[path]={readable:false,error:error.name};}
}
results.serverEvents=await (await fetch('/events')).json();
window.labResult=results;
document.getElementById('result').textContent=JSON.stringify(results,null,2);
}
run().catch(error=>{window.labError=String(error);});
</script>""").encode()
kind = "text/html; charset=utf-8"
self.send_response(200)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
api = HTTPServer(("127.0.0.1", 0), API)
app = HTTPServer(("127.0.0.1", 0), App)
api_origin = f"http://127.0.0.1:{api.server_port}"
app_origin = f"http://127.0.0.1:{app.server_port}"
threads = [Thread(target=server.serve_forever, daemon=True) for server in (api, app)]
for thread in threads:
thread.start()
try:
request = Request(api_origin + "/blocked", data=b"synthetic-change",
headers={"Origin": app_origin, "Content-Type": "text/plain"})
with urlopen(request, timeout=5) as response:
assert response.status == 200 and response.read() == b"synthetic action recorded"
assert response.headers.get("Access-Control-Allow-Origin") is None
assert len(events) == 1 and events[0]["method"] == "POST"
events.clear()
print(json.dumps({"app_url": app_origin, "nonbrowser_read_without_cors": True}), flush=True)
if "--serve" in sys.argv:
Event().wait()
except KeyboardInterrupt:
pass
finally:
for server in (api, app):
server.shutdown()
server.server_close()
for thread in threads:
thread.join(timeout=5)
The page sends identical POST bodies to /allowed and /blocked. The first response permits the page origin and is readable. The second omits the allow-origin header, so fetch rejects with a TypeError. Nevertheless, serverEvents contains both POST requests and no OPTIONS request. The browser check requires those exact observations, rather than interpreting a rejected JavaScript promise as evidence that the second request was prevented.
Before serving the page, the Python probe also reads the blocked endpoint’s body successfully without an allow-origin header, demonstrating the non-browser boundary. Its event is cleared so it cannot inflate the browser result. This exercise verifies request delivery and response exposure for the supplied simple requests. It does not implement an authentication or CSRF system. When configuring a real API, review permitted page origins, actual request shapes, and accepted business actions separately, then verify the complete browser-to-server path.
Sources
- MDN Cross-Origin Resource Sharing, response exposure, simple requests, and the CSRF boundary, checked 2026-09-13.
- WHATWG Fetch Standard, the browser CORS protocol and response filtering, checked 2026-09-13.