CORS preflight asks whether a browser may send a particular cross-origin request shape. It is an OPTIONS request containing the proposed method and relevant request-header names. The browser checks the server’s response before sending the actual request. This mechanism is distinct from checking whether application JavaScript may read an actual response that has already arrived. Both stages can fail, but their effects on server-side execution differ.
The trigger is not simply “credentials are present” or “the request has side effects.” Safelisted GET, HEAD, or POST requests can avoid preflight when the other conditions are satisfied. A PUT or an application-supplied header such as Authorization generally requires it; a JSON content type also falls outside the safelisted content types. Changing request shape can therefore introduce an OPTIONS exchange even when the destination and business operation stay the same.
Match each permission to its stage
The preflight carries Access-Control-Request-Method and, when relevant, Access-Control-Request-Headers. For the PUT and X-Lab request below, the preflight response must authorize both the method and the custom header. Success at that stage does not remove the actual response’s CORS requirements. Error responses can also need the appropriate exposure headers when the permitted frontend must inspect their status and body. A functioning backend handler alone does not establish browser accessibility.
Credentials mode adds another independent constraint. With credentials: include, response exposure requires a matching explicit origin and Access-Control-Allow-Credentials: true; an allow-origin wildcard fails even if this particular test has no cookie to send. Browser cookie restrictions still apply independently. Do not use the credentials header as a substitute for cookie configuration, authentication, or an origin allowlist, and do not require session cookies merely to answer the browser’s preflight metadata query.
When it breaks
An API may permit PUT in its application router but omit PUT or a custom header from the preflight response. The browser then sends OPTIONS and never dispatches the actual PUT. Retrying the business handler or changing its database code will not fix that boundary. Conversely, a simple credential-mode GET can reach the handler without OPTIONS and then fail response exposure because the server returned a wildcard.
Preflight caching can obscure configuration changes during diagnosis. Access-Control-Max-Age controls a dedicated browser permission cache, subject to browser limits; it is not a cache of the API’s response body. Inspect the actual exchanges, including cache effects, before declaring a policy change ineffective. Where a response varies by requesting origin, configure cache variation appropriately so a shared cache does not reuse one origin’s selected permission for another request.
Check it quickly
Save the script as cors-preflight.py and run python3 cors-preflight.py --serve. Open the printed page and close both local servers with Control-C afterward. The fixture uses separate loopback ports and synthetic responses. It intentionally tests credentials mode without setting cookies or implementing authentication, so it demonstrates protocol checks rather than a complete credential-delivery setup.
Validation note, 17 October 2026: The revised loopback fixture passed its monitored startup checks with Python 3.14.4 and five sequential browser cases in headless Chrome 156.0.8078.36 using a fresh private profile. Wildcard plus include was unreadable; wildcard plus omit and exact-origin plus include were readable. The denied PUT stopped at preflight; the permitted PUT reached the server. Observed 204 responses omitted Content-Length. The private Python opener handles only startup OPTIONS; additional monitoring supplied connected-peer checks that the article helper does not contain. Normal browser CORS remained active. These synthetic results do not demonstrate real authentication or production behavior. Separate raw-response checks of the byte-identical API handler found empty 204s without Content-Length or Transfer-Encoding. Six offline malformed-response controls were rejected.
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
events = []
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
raise ValueError("This fixture does not follow redirects")
def open_owned_preflight(request):
endpoint = api.socket.getsockname()
if (endpoint != ("127.0.0.1", api.server_port)
or type(endpoint[1]) is not int or not 1024 <= endpoint[1] <= 65535
or request.full_url not in {f"http://127.0.0.1:{endpoint[1]}/{p}" for p in ("deny", "permit")}
or request.get_method() != "OPTIONS" or request.data is not None
or request.has_proxy()):
raise ValueError("Only this fixture's owned loopback preflights are allowed")
return direct_opener.open(request, timeout=5)
class API(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def reply(self, preflight=False):
events.append({"method": self.command, "path": self.path,
"requested_method": self.headers.get("Access-Control-Request-Method"),
"requested_headers": self.headers.get("Access-Control-Request-Headers")})
body = b"" if preflight else b"readable result"
self.send_response(204 if preflight else 200)
if not preflight:
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*" if self.path == "/wildcard" else app_origin)
self.send_header("Access-Control-Allow-Credentials", "true")
self.send_header("Vary", "Origin")
if preflight and self.path == "/permit":
self.send_header("Access-Control-Allow-Methods", "PUT")
self.send_header("Access-Control-Allow-Headers", "X-Lab")
self.send_header("Access-Control-Max-Age", "0")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.reply(preflight=True)
def do_GET(self):
self.reply()
def do_PUT(self):
self.rfile.read(int(self.headers.get("Content-Length", "0")))
self.reply()
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 preflight lab</title>
<h1>Credentials mode and preflight</h1><pre id="result">Running...</pre><script>
const api=""" + json.dumps(api_origin) + """;
async function run() {
const cases=[
['wildcardInclude','/wildcard',{credentials:'include'}],
['wildcardOmit','/wildcard',{credentials:'omit'}],
['exactInclude','/exact',{credentials:'include'}],
['deniedPut','/deny',{method:'PUT',headers:{'X-Lab':'demo'},body:'synthetic'}],
['allowedPut','/permit',{method:'PUT',headers:{'X-Lab':'demo'},body:'synthetic'}]
];
const results={};
for(const [name,path,options] of cases) {
try {const response=await fetch(api+path,options);results[name]={readable:true,status:response.status,body:await response.text()};}
catch(error) {results[name]={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}"
# A process-local client for these two owned loopback requests only.
# It changes no system settings or global urllib opener.
direct_opener = build_opener(ProxyHandler({}), NoRedirect())
threads = [Thread(target=server.serve_forever, daemon=True) for server in (api, app)]
for thread in threads:
thread.start()
try:
headers = {"Origin": app_origin, "Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "x-lab"}
for path in ("deny", "permit"):
with open_owned_preflight(Request(api_origin + "/" + path, method="OPTIONS", headers=headers)) as response:
assert response.status == 204
assert response.headers.get("Content-Length") is None
assert response.headers.get("Access-Control-Allow-Methods") == ("PUT" if path == "permit" else None)
assert response.headers.get("Access-Control-Allow-Headers") == ("X-Lab" if path == "permit" else None)
events.clear()
print(json.dumps({"app_url": app_origin, "wire_preflight_checks": "PASS",
"real_authentication_or_cookies": False}), 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)
Five fetch cases run. Wildcard plus include is unreadable; the same wildcard with omit is readable. An exact origin plus include is readable. The denied PUT fails preflight because its permissions are omitted, while the permitted PUT succeeds with an explicitly allowed method and X-Lab header. The page displays server events, not just JavaScript success flags, so the execution boundary can be checked directly.
Expect three GET events, two OPTIONS events, and exactly one PUT to /permit. Both preflights request PUT and the lowercase header name x-lab; no PUT reaches /deny. The permit response sets max-age to zero to avoid relying on cached permission. The default Python-only run checks wire headers, while the browser run verifies dispatch and exposure. Preserve those distinct checks when adapting the example, and test the actual method, content type, custom headers, credentials mode, and response path used by your frontend.
Sources
- MDN CORS, preflight criteria and credentialed request exchanges, checked 2026-09-13.
- MDN Access-Control-Allow-Credentials, credential mode requirements for exposing a response, checked 2026-09-13.
- WHATWG Fetch Standard, method and header permission checks and the CORS-preflight cache, checked 2026-09-13.