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 four preflight gates, in evaluation order Four boxes in a row represent the status gate, the origin gate, the method gate and the header gate. Each has an arrow down to a shared failure strip, showing that any single failure ends the fetch as a network error before the real request is sent. The preflight response is judged by four gates, and they run in this order 1. Status gate is the code 200-299? this page lives here 2. Origin gate does Allow-Origin match the page origin? 3. Method gate does Allow-Methods list the real method? 4. Header gate does Allow-Headers cover each asked field? Any gate that fails ends the fetch as a network error, and the real request is never sent Gate 1 runs before a single header is parsed, so 401, 403, 404, 405, 301 and 500 all fail here however complete the Access-Control headers on that same response happen to be

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

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.

Which layer answers the preflight, and with what status A vertical stack of five request-path layers on the left is paired with the status each typically returns to an OPTIONS request on the right. Only the deepest layer, the route handler, produces a passing 204, so any layer above it that answers first decides the outcome. The first layer that decides to answer the OPTIONS owns the status the browser sees Edge firewall or WAF 403 - a rule drops the bodyless OPTIONS Load balancer or ingress 404 - no listener rule matches OPTIONS Reverse proxy 405 - the upstream refuses the method Authentication middleware 401 - the preflight carries no credentials Route handler 204 - the only layer you configured Put the handler above whichever layer is answering first, or the fix you deploy never runs

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);
Middleware order decides which layer answers the preflight The left stack runs the authentication gate before the CORS layer, so the preflight is rejected with 401 and the CORS layer is never reached. The right stack runs the CORS layer first, so the preflight ends there with a 204 and the remaining middleware runs only for the real request. As deployed After the reorder OPTIONS preflight arrives helmet() adds headers, passes the request on requireBearerToken() 401, the response ends here cors(corsOptions) never reached router never reached OPTIONS preflight arrives helmet() adds headers, passes the request on cors(corsOptions) 204, the response ends here requireBearerToken() skipped for OPTIONS only router runs for the real PATCH The rule is positional: whatever answers OPTIONS must sit above every gate that can reject it

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.