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. The response’s allow-methods and allow-headers fields must permit what the browser proposes. 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. Python 3.14.4 and Chrome 152 were used. 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.

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 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)
        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}"
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 urlopen(Request(api_origin + "/" + path, method="OPTIONS", headers=headers), timeout=5) as response:
            assert response.status == 204
            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