A CORS preflight asks whether a browser may send a particular cross-origin request. It describes the requesting origin, intended method, and relevant header names. The browser evaluates the reply before sending that request, unless a valid preflight cache entry already grants permission. CORS also governs whether JavaScript can read the actual response. These are related checks, but neither replaces application authentication or authorization.

The trigger is the request shape

An origin consists of scheme, host, and port. Changing any of them can make a fetch cross-origin. The browser does not decide to preflight by guessing whether the server will modify data. A qualifying POST with Content-Type: text/plain can be sent without preflight, while a read-only request using an Authorization header normally needs one. Methods, header values, and other Fetch conditions determine whether the request qualifies for the safelist.

A JSON POST normally triggers preflight because application/json is not a safelisted content type. The browser sends OPTIONS to the target with Origin, Access-Control-Request-Method: POST, and an Access-Control-Request-Headers list containing content-type. It sends the header names, not their future secret values. The response must allow the origin, requested method, and relevant headers for the browser to proceed.

Apply the policy to both responses

For a restricted API, compare the origin against an exact allowlist and return one allowed origin in Access-Control-Allow-Origin. Do not return a comma-separated list or reflect arbitrary input. A public resource intended for any origin can use * for requests without credentials. Credentialed access instead requires the explicit origin and Access-Control-Allow-Credentials: true; browser cookie restrictions still apply independently.

The actual response also needs the applicable CORS fields, including on errors that the calling script is meant to read. A successful preflight does not authorize reading a later response missing those fields. When selection depends on Origin, include Vary: Origin so shared caches distinguish variants. Access-Control-Max-Age controls a dedicated preflight cache, subject to browser limits; ordinary HTTP cache behavior alone does not explain every missing OPTIONS request.

Allowing request headers and exposing response headers are separate permissions. For example, permitting a JSON POST does not automatically let JavaScript read a custom response header such as X-Request-ID; use Access-Control-Expose-Headers when that metadata is needed. Switching fetch to mode: "no-cors" is not a way to inspect a rejected response. It restricts the request and produces an opaque response whose status, headers, and body are unavailable to the calling script.

When it breaks

An authentication gateway may reject OPTIONS because the preflight lacks session cookies. Fetch preflights normally exclude those credentials, so evaluate the CORS policy before requiring the actual operation’s login. The subsequent request still needs normal authentication and permission checks. Opening the operation itself to anonymous users is not a fix for a rejected preflight.

A form-compatible POST can reach the server even when JavaScript cannot read its response. Treating a CORS error as proof that no write happened can cause duplicate submissions or leave CSRF vulnerabilities. Use the application’s CSRF defenses and idempotency rules for those concerns. Another common fault is two proxy layers adding duplicate allow-origin fields, which browsers reject even though each layer’s configuration appears plausible in isolation.

Check it quickly

Run this Python 3 fixture on loopback. It allows one origin, POST, and the content-type header during preflight, then tests a denied origin and a denied header. Its POST handler deliberately accepts a test body without authentication to demonstrate why response-read restrictions do not protect writes. It is not a production API.

from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread

ORIGIN = "https://reader.example"

class Handler(BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        names = {s.strip().lower() for s in self.headers.get(
            "Access-Control-Request-Headers", "").split(",") if s.strip()}
        allowed = (self.headers.get("Origin") == ORIGIN
                   and self.headers.get("Access-Control-Request-Method") == "POST"
                   and names <= {"content-type"})
        self.send_response(204 if allowed else 403)
        self.send_header("Content-Length", "0")
        self.send_header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
        if allowed:
            self.send_header("Access-Control-Allow-Origin", ORIGIN)
            self.send_header("Access-Control-Allow-Methods", "POST")
            self.send_header("Access-Control-Allow-Headers", "content-type")
        self.end_headers()

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", "0")))
        self.server.posts += 1
        self.send_response(200)
        self.send_header("Content-Length", "2")
        self.end_headers()
        self.wfile.write(b"ok")

    def log_message(self, *args):
        pass

with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
    server.posts = 0
    worker = Thread(target=server.serve_forever, daemon=True)
    worker.start()
    client = HTTPConnection(*server.server_address, timeout=3)
    try:
        for origin, names, expected in [
            (ORIGIN, "content-type", 204),
            ("https://untrusted.example", "content-type", 403),
            (ORIGIN, "authorization", 403),
        ]:
            client.request("OPTIONS", "/", headers={"Origin": origin,
                "Access-Control-Request-Method": "POST",
                "Access-Control-Request-Headers": names})
            response = client.getresponse()
            response.read()
            assert response.status == expected
            assert response.getheader("Access-Control-Allow-Origin") == (
                ORIGIN if expected == 204 else None)
            print("preflight", expected)
        client.request("POST", "/", body="test", headers={
            "Origin": "https://untrusted.example", "Content-Type": "text/plain"})
        response = client.getresponse()
        assert response.read() == b"ok"
        assert response.getheader("Access-Control-Allow-Origin") is None
        assert server.posts == 1
        print("POST reached server without a readable CORS response")
    finally:
        client.close()
        server.shutdown()
        worker.join()

Expect statuses 204, 403, and 403, followed by confirmation that the POST reached the handler. This verifies the server-side decision and response fields. Python does not enforce CORS, so it cannot establish browser behavior. In a browser integration test, inspect OPTIONS and the subsequent request separately: a denied JSON preflight should suppress the POST, while a qualifying simple POST may execute and still reject the script’s read. Preserve server-side request evidence when interpreting that distinction.

Sources