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

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:

A clean 204 preflight next to a 204 that carries content Two panels of raw response bytes. The left panel shows a 204 with CORS headers and no body or Content-Length. The right panel shows a 204 that declares a Content-Length and returns a JSON body, which contradicts the status and can desynchronise a reused connection. What a preflight 204 should look like The 204 that breaks connection reuse HTTP/1.1 204 No Content access-control-allow-origin: https://studio.nimbusgrid.com access-control-allow-methods: GET, POST access-control-max-age: 600 vary: origin no body, no content-length The connection stays clean, so the real POST reuses it without a new handshake HTTP/1.1 204 No Content content-length: 26 content-type: application/json access-control-allow-origin: https://studio.nimbusgrid.com {"status":"preflight ok"} status says empty, headers say 26 bytes A 204 must not carry content — the peer may stall or drop the next pipelined request The CORS headers are identical in both — the difference is entirely in the framing

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:

The four consumers of a preflight status Four gates in sequence, each with the failure it produces. HTTP framing rejects a 204 that carries a body, the reverse proxy drops headers on statuses outside its default list, the CDN re-fetches statuses it does not cache, and the browser check rejects any non-2xx status. HTTP framing parser + keep-alive Reverse proxy header rules by status CDN cache cacheable status set Browser check requires an ok status a 204 with a body desyncs the stream and kills keep-alive add_header skips statuses outside its list — use always a status the CDN will not cache is fetched every time any non-2xx fails: 401, 403, 404, 405 200 and 204 both pass Only the last gate is about CORS — the first three decide which of the two statuses you should send Check the status your CDN actually caches: a preflight it refuses to cache reaches the origin on every call

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:

Choosing the preflight success status A left-to-right flow. If every consumer is a browser or a modern HTTP client the answer is 204 with no body. If not, and the exception can be scoped, return 200 on that route only; if it cannot be scoped, return 200 everywhere with an empty body. Which success status should this OPTIONS handler return? One route, one preflight handler Is every consumer a browser or a modern HTTP client? yes Return 204 No Content — no body, no Content-Length no Can the exception be scoped to that one route? yes Return 200 with an empty body on that route only no Return 200 everywhere and keep the body empty Both branches satisfy the browser; 204 is the default and 200 is a deliberate, documented exception

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.