Returning 204 vs 200 for OPTIONS Requests
The console error that starts most of these investigations:
Access to fetch at 'https://api.nimbusgrid.com/v1/exports' from origin
'https://studio.nimbusgrid.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: It does not have HTTP ok
status.
Root Cause
That message is not a verdict on 204 versus 200. The Fetch algorithm requires the preflight response to carry an ok status — anything from 200 to 299 — before it will read the Access-Control-* headers, and both candidates are inside that range. The error appears when the OPTIONS request reached something that answered 401, 403, 404, 405 or 500: an authentication filter, a router with no OPTIONS route, or a framework that rejects the method before any CORS logic runs. Swapping 204 for 200 will not fix it, and a surprising amount of time gets burned trying.
The real decision between the two statuses is made below the CORS layer, by HTTP framing rules, by proxy configuration that keys on status, and by CDN cache policy. This page sits under OPTIONS Endpoint Design for CORS Preflights, which covers the routing and header set of the endpoint itself; here the subject is only the status line and the bytes that may follow it.
| Status | Ok status for the preflight check | RFC 9110 semantics | Body permitted | Where you usually see it |
|---|---|---|---|---|
204 No Content |
yes | success, and the response must not include content | no | Nginx return 204, hand-written handlers, most middleware defaults |
200 OK |
yes | success with a representation of the result | yes, including an empty one | managed gateways, framework CORS defaults, legacy compatibility modes |
403 / 404 / 405 |
no | the request was refused or unrouted | — | auth filter or router reached before the preflight handler |
301 / 302 |
no | redirection; the browser will not follow it for a preflight | — | trailing-slash or HTTPS canonicalisation rules |
Prerequisite State
- The
OPTIONSroute is reachable without authentication, so the status you see is the one your CORS handler chose rather than the one your auth filter chose. - Exactly one layer answers preflights for the path. Two layers answering produces duplicated headers regardless of status.
- You can run
curlagainst both the public hostname and the origin server, so you can tell a gateway’s answer from your application’s. - The
Access-Control-Allow-Origin,-Methodsand-Headersvalues are already correct for the route.
Step-by-Step
Step 1 — See what is actually on the wire
Before changing anything, look at the response line and the framing headers together:
curl -sS -D - -o /dev/null -X OPTIONS https://api.nimbusgrid.com/v1/exports \
-H 'Origin: https://studio.nimbusgrid.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
A correct preflight and a subtly broken one differ by two lines that are easy to skim past:
Step 2 — Return 204 with nothing after the headers
204 is the default answer because it states exactly what happened: the request was understood, permission is described in the headers, and there is nothing to represent. In Express, use .end() rather than a send helper, so no serialiser gets a chance to attach a body:
const express = require("express");
const app = express();
app.options(/^\/v1\//, (req, res) => {
if (req.headers.origin !== "https://studio.nimbusgrid.com") {
return res.status(403).end();
}
res.set({
"Access-Control-Allow-Origin": "https://studio.nimbusgrid.com",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "600",
"Vary": "Origin",
});
return res.status(204).end(); // no body, no Content-Type, no Content-Length
});
In Nginx the same answer is a single directive, and the always flag matters even here — without it, add_header applies only to a fixed list of statuses, so the day someone changes return 204 to return 202 the headers silently disappear:
location /v1/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://studio.nimbusgrid.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age "600" always;
add_header Vary "Origin" always;
return 204;
}
proxy_pass http://exports_upstream;
}
Spring MVC expresses it through ResponseEntity, which builds a genuinely bodiless response:
@RestController
public class PreflightController {
@RequestMapping(value = "/v1/**", method = RequestMethod.OPTIONS)
public ResponseEntity<Void> preflight() {
return ResponseEntity.noContent()
.header("Access-Control-Allow-Origin", "https://studio.nimbusgrid.com")
.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
.header("Access-Control-Allow-Headers", "Authorization, Content-Type")
.header("Access-Control-Max-Age", "600")
.header("Vary", "Origin")
.build();
}
}
Step 3 — Check the status survives the layers in front of it
The preflight response passes through several consumers before the CORS check ever runs, and each one treats the status differently:
Whichever status you choose, keep Vary: Origin on the response or a shared cache will hand one origin’s grant to another — the mechanics are in Handling Vary: Origin Header Correctly.
Step 4 — Scope the 200 exception, do not globalise it
200 earns its place when something in the chain is not a browser: an old XHR wrapper that treats an empty response as a failure, a corporate proxy that strips bodiless responses, or a managed gateway whose generated integration returns 200 and cannot be changed. Confine the exception to the route that needs it:
const cors = require("cors");
// Only this partner-facing route answers 200; everything else keeps 204.
app.use(
"/v1/legacy-partner",
cors({
origin: "https://studio.nimbusgrid.com",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"],
maxAge: 600,
optionsSuccessStatus: 200,
})
);
The choice, end to end:
Step 5 — Keep the two statuses out of the same path
The one configuration that is always wrong is a path where the status depends on which layer answered — an edge worker returning 204 on a cache miss and a gateway integration returning 200 on a hit, for example. The CORS headers usually differ too, so the failure is intermittent and origin-specific. Pick the owning layer, delete the other handler, and re-test through the public hostname; the same discipline is applied to edge termination in Handling CORS Preflight in Cloudflare Workers and to managed gateways in Configuring CORS Preflight in AWS API Gateway.
Verification
curl -sS -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' \
-X OPTIONS https://api.nimbusgrid.com/v1/exports \
-H 'Origin: https://studio.nimbusgrid.com' \
-H 'Access-Control-Request-Method: POST'
Security Boundary Note
A success status is not permission. Returning 204 to every OPTIONS request regardless of the Origin value is fine — and is often the cleanest design, because the browser blocks the real request when the headers are absent — but only if the headers really are absent for unknown origins. What must never happen is a handler that answers 204 with a reflected origin before the allowlist check, on the theory that a bodiless response is harmless: that grant is exactly what an attacker’s page needs, and pairing it with Access-Control-Allow-Credentials: true exposes authenticated data. Equally, do not let a permissive preflight paper over an authentication failure on the real method; the preflight must never be the place where authorisation is decided.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
Switching 204 to 200 to fix “does not have HTTP ok status” |
Both are ok statuses, so the real non-2xx answer from an auth filter or router stays unfixed | Find the layer returning 401/404/405 and let the preflight handler run first |
Returning 204 with a JSON body attached by a serialiser |
The declared length contradicts the status; connection reuse can stall or drop the next request | Terminate with .end() or the framework’s explicit no-content builder |
Relying on add_header without always |
Headers vanish the moment the status leaves the default list | Add always to every CORS add_header on the preflight path |
| Two layers answering the same path with different statuses | Intermittent, origin-specific failures that reproduce only on a cache miss | Give one layer ownership and remove the duplicate handler |
FAQ
Does the browser care whether the preflight returns 200 or 204?
No. The Fetch algorithm requires the preflight response to have an ok status, which is any status from 200 to 299 inclusive, and then reads the Access-Control-* headers from it. A 204 and a 200 are equally valid, and switching between them never fixes a blocked request on its own. If the console says the preflight does not have HTTP ok status, the handler returned something outside that range, typically 401, 403, 404 or 405.
Can a 204 preflight response carry a Content-Length header?
A 204 must not carry content, and the safest form omits Content-Length entirely. A Content-Length of zero is tolerated by every current client, but a non-zero value is a framing violation: the peer expects bytes that never arrive, so the connection can stall or be torn down and the next request on it is lost. If your framework attaches a body to 204 responses, strip it rather than adjusting the length.
Why does my API gateway answer OPTIONS with 200 when my application returns 204?
Managed gateways usually answer the preflight themselves with a mock integration rather than forwarding it, and those integrations are generated with a 200 and a static header set. The application handler never runs, so its 204 is irrelevant. Decide which layer owns the preflight, remove the handler from the other, and verify with curl against the public hostname rather than the origin server.