Fixing a Preflight Response That Is Not Successful
The request never reaches your handler, and each browser phrases the same rejection differently:
Chrome: Access to fetch at 'https://api.northwind-logistics.com/v2/shipments/8812'
from origin 'https://portal.northwind-logistics.com' has been blocked by CORS
policy: Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.
Safari: Preflight response is not successful. Status code: 401
Firefox: Cross-Origin Request Blocked: The Same Origin Policy disallows reading the
remote resource. (Reason: CORS preflight response did not succeed).
Status code: 405.
Safari and Firefox hand you the number; Chrome makes you go and find it. That number is the whole diagnosis, because the fix depends entirely on which layer produced it.
Root Cause
The Fetch Standard treats the preflight as a gate with an ordered set of tests, and the very first one is the status. If the response status is not in the 200–299 range, the CORS-preflight fetch returns a network error immediately — before Access-Control-Allow-Origin is parsed, before Access-Control-Allow-Methods is compared, before anything else on the response is looked at. A preflight can carry a flawless set of CORS headers and still fail if it arrives with a 401, and no amount of header tuning will change that.
The reason OPTIONS so often draws a rejection status is that a preflight is deliberately anonymous. The specification requires the browser to send it with credentials mode set to omit: no cookies, no Authorization header, no client certificate, and none of the custom headers the real request will carry. Every authentication layer in the path therefore sees an unauthenticated request for a resource it is guarding, and answers exactly as it was configured to. This page belongs to CORS Error Code Breakdown, which maps the whole family of browser-side CORS failures; here we take the status-code branch to the bottom.
| Status | Layer that usually emits it | Why the preflight lands on it |
|---|---|---|
401 |
Authentication middleware, gateway authorizer | The preflight carries no token or cookie, so the gate rejects it as anonymous |
403 |
Edge firewall, WAF rule, bot filter | A rule matches a bodyless request with an unusual method and drops it |
404 |
Application router | The route was registered for POST only, so OPTIONS matches nothing |
405 |
Framework or reverse proxy | The path exists but the method table has no OPTIONS entry |
301 / 308 |
Redirect rule, trailing-slash normaliser, host canonicaliser | A preflight response that redirects is a network error, never followed |
500 |
Application code | Middleware assumes a parsed body and throws on the empty preflight |
502 / 504 |
Reverse proxy | The upstream refuses or ignores OPTIONS and the proxy times out |
Prerequisite State
- A browser page on
https://portal.northwind-logistics.comcallinghttps://api.northwind-logistics.com, with the API already returning correct CORS headers on real responses. - Shell access to run
curlfrom outside your network, so you traverse the same edge layers the browser does. - The ability to change middleware order in the application, or the proxy configuration in front of it — one of the two is enough.
- Knowledge of which components sit in the request path. If that is unclear, work through Troubleshooting CORS at the Proxy Layer first.
Step-by-Step Fix
Step 1 — Capture the exact status
Chrome hides the number, so take it from curl. Reproduce the preflight faithfully: the method, the origin, and the two request headers the browser would send.
curl -s -o /dev/null -w 'preflight status: %{http_code}\n' \
-X OPTIONS https://api.northwind-logistics.com/v2/shipments/8812 \
-H 'Origin: https://portal.northwind-logistics.com' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: authorization, content-type'
Do not add -H 'Authorization: Bearer ...' to this probe. A real preflight never carries one, and adding it produces a 204 that hides the very bug you are chasing — the single most common false negative in CORS debugging.
Step 2 — Find the layer that answered
The status alone narrows the field; the response fingerprint closes it. Dump all headers and read the ones the infrastructure adds about itself.
curl -sD - -o /dev/null -X OPTIONS https://api.northwind-logistics.com/v2/shipments/8812 \
-H 'Origin: https://portal.northwind-logistics.com' \
-H 'Access-Control-Request-Method: PATCH'
A cf-ray line means the request never left the edge. x-amzn-requestid or x-amz-apigw-id points at an API gateway. server: nginx with no application header means the reverse proxy answered on its own. If the only fingerprint is your framework’s, the request reached the application and the problem is middleware order.
Step 3 — Answer OPTIONS above the authentication gate
If the fingerprint says the application answered, the fix is ordering. In Express, the CORS middleware must be registered before anything that can reject a request, because cors() ends the response for a preflight and never calls the next handler.
const express = require("express");
const cors = require("cors");
const app = express();
const corsOptions = {
origin: ["https://portal.northwind-logistics.com"],
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE"],
allowedHeaders: ["Authorization", "Content-Type", "X-Tenant-Id"],
maxAge: 600,
optionsSuccessStatus: 204,
};
// 1. CORS first — it answers OPTIONS with 204 and returns without calling next()
app.use(cors(corsOptions));
// 2. Only then the gate that can produce a 401
app.use(requireBearerToken);
app.patch("/v2/shipments/:id", updateShipment);
app.listen(8080);
The dashed boxes on the right are skipped for the preflight only. The real PATCH still travels the full stack and is still authenticated — moving the CORS layer up does not weaken the gate, because the preflight it answers carries no credentials to check in the first place.
Step 4 — Terminate the preflight at the proxy when the app is not reachable
When the 403, 404 or 405 comes from a layer in front of the application, the application-side fix cannot run. Answer the preflight where the block happens instead. In Nginx, place the short-circuit above the authentication subrequest so return 204 fires before auth_request is evaluated:
location /v2/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://portal.northwind-logistics.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Tenant-Id" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age "600" always;
add_header Vary "Origin" always;
add_header Content-Length 0;
return 204;
}
auth_request /internal/verify;
proxy_pass http://shipments_upstream;
}
The always flag matters: without it Nginx drops add_header values on non-2xx responses, so the day something upstream returns a 502 the CORS headers vanish and the browser reports a different error entirely. If the offending layer is a managed firewall rather than your own proxy, add an allow rule for OPTIONS on the API path — the same manoeuvre described in Debugging CORS Failures Behind Cloudflare.
Step 5 — Remove redirects from the preflight path
A 301 or 308 is a network error for a preflight; the browser does not follow it. Two everyday sources produce one:
# Trailing-slash normalisation: the client calls the uncanonical form
curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' \
-X OPTIONS https://api.northwind-logistics.com/v2/shipments \
-H 'Origin: https://portal.northwind-logistics.com' \
-H 'Access-Control-Request-Method: PATCH'
If that prints 308 -> https://api.northwind-logistics.com/v2/shipments/, the fix belongs in the client: call the canonical URL that the server does not need to rewrite. The second source is host canonicalisation — an apex-to-www or bare-to-subdomain redirect that the real request would have followed silently. Point the client at the final host so no hop is generated, rather than trying to make the redirect CORS-aware.
Verification
Re-run the Step 1 probe and read the first line of the response, not just the body:
curl -si -X OPTIONS https://api.northwind-logistics.com/v2/shipments/8812 \
-H 'Origin: https://portal.northwind-logistics.com' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: authorization, content-type' | head -12
Expect HTTP/2 204 followed by access-control-allow-origin, access-control-allow-methods, access-control-allow-headers and vary: origin. Then confirm the same thing in the browser:
More on isolating a row-by-row read of the panel is in Inspecting Preflight in the DevTools Network Panel.
Security Boundary Note
Making OPTIONS succeed anonymously is safe, and refusing to do it is what breaks the browser. A preflight response carries no application data: it is a 204 with a fixed set of policy headers and an empty body. What you must not do is generalise the exemption. A rule such as “skip authentication when the method is OPTIONS” is correct; a rule such as “skip authentication when the request has an Origin header” or “skip authentication for this whole path prefix” hands an attacker a bypass for the real request too. Keep the exemption scoped to the method, keep the response body empty, and never let the preflight branch fall through into the handler that serves data. The allowlist that decides which origins get an echo should be exactly as strict as before — see Dynamic Origin Validation Patterns for the shape that holds up.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
Adding an Authorization header to the curl probe |
The probe returns 204 while the browser keeps failing, and the real cause stays invisible for hours |
Reproduce the preflight exactly: origin plus the two Access-Control-Request-* headers, no credentials |
| Registering the CORS middleware after the authentication middleware | The gate answers 401 and the CORS layer never runs, so no response header can help |
Register the CORS layer first, above every middleware that can end a request |
| Fixing only the application when the block is at the edge | The deploy changes nothing because the request never arrives; the same status keeps coming back | Read the fingerprint headers first, then fix at the layer that actually answered |
| Leaving a trailing-slash or host redirect on the API path | A 308 preflight is a network error, and the console message looks identical to a missing header |
Call the canonical URL from the client so no redirect is generated |
FAQ
Why does the preflight get a 401 when my access token is perfectly valid?
Because the preflight never carries the token. A CORS-preflight fetch is defined to omit credentials entirely: no cookies, no Authorization header, no client certificate. Your authentication middleware therefore sees an anonymous request and answers 401 before your route or your CORS layer is ever consulted. The fix is positional, not credential-related: answer OPTIONS above the authentication gate.
Is 204 required, or will a 200 preflight response work?
Any status in the 200 to 299 range satisfies the check, so 200 and 204 both work. 204 is preferred because it declares that there is no body, which keeps the response small and avoids intermediaries trying to compress or transform an empty payload. If you do return 200, send it with Content-Length: 0 rather than an empty JSON object, and never send a body that a proxy might cache under the wrong key.
Does the browser follow a redirect returned to a preflight?
No. A redirect response to a CORS-preflight fetch is a network error by definition, so a 301 or 308 fails exactly like a 500 would. This bites hardest on frameworks that append a trailing slash and on load balancer rules that normalise paths, because the equivalent redirect on the real request would have been followed silently. Call the canonical URL from the client so no redirect is ever generated.