OPTIONS preflight requests: CORS headers stripped before reaching the browser
marktoma96
HOBBYOP

2 months ago

My Express service correctly sets CORS headers (Access-Control-Allow-Origin, -Headers, -Methods) on every response, including OPTIONS preflight requests — verified in the code and confirmed working correctly when run locally, outside of Railway.

In production (varcoapp-production.up.railway.app), OPTIONS requests to any endpoint (/api/claude, /state, /healthz, /batch/run) receive a response but with no headers at all: verified via fetch() from the browser, the headers.entries() array is empty except for content-length: 0.

I've already tried the two most common variants for this kind of issue:

  • OPTIONS response with status 204
  • OPTIONS response with status 200, handled explicitly via a dedicated route instead of a generic middleware

Neither changes the result: headers remain absent in both cases. This rules out an issue in my own code.

Service: VARCO_app (877eeb4d-87d4-4c27-ba5f-8e1ecf0d35d8)

Project: 682a4457-26e9-4e59-bdf5-0f11a3faa50a

Question: is there an edge/proxy layer filtering these headers on OPTIONS responses? How do I disable it or configure it so headers pass through intact to the container?

Awaiting Conductor Response$10 Bounty

2 Replies

Railway
BOT

2 months ago

This thread has been opened as a bounty so the community can help solve it.

Status changed to Open Railway • about 2 months ago


vitakili
PRO

2 months ago

I’m seeing the same behavior in production: OPTIONS requests return successfully, but none of the CORS headers from my Express app make it through. I’ve confirmed the middleware works locally, and I’ve also tried both 204 and 200 responses with explicit OPTIONS handling.

Could you check whether Railway’s edge/proxy is handling these OPTIONS requests before they reach the container? If so, is there a setting to disable that behavior or allow the response headers to pass through unchanged?


testuchunmaxsus
PRO

2 months ago

Before chasing Railway's edge: the measurement itself cannot show what you are looking for. What you are seeing is the expected output of a working CORS setup.

Why headers.entries() is empty

On a cross-origin response, JavaScript can only read the CORS-safelisted response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. Everything else is hidden unless the server also sends Access-Control-Expose-Headers naming it.

Access-Control-Allow-Origin is never in that list and cannot be added to it. The browser consumes it while performing the CORS check and does not hand it to your script. So "the headers.entries() array is empty except for content-length" is exactly what you get when the headers are present and correct. It is not evidence of anything being stripped.

There is a second layer to this: the real preflight is issued by the browser internally and is not observable from JavaScript at all. If you called fetch(url, { method: "OPTIONS" }) by hand, that is a different request from the preflight the browser sends ahead of your actual POST.

You and vitakili used the same method, which is why you both see the same thing.

How to actually measure it

curl -i -X OPTIONS https://varcoapp-production.up.railway.app/state -H "Origin: https://example.com" -H "Access-Control-Request-Method: POST"

The Origin and Access-Control-Request-Method headers matter. The Express cors middleware only emits CORS headers when Origin is present, so a plain curl without them will legitimately return nothing and send you back down the wrong path.

Alternatively use DevTools, Network tab, find the OPTIONS entry (turn on the preflight requests filter) and read Response Headers there. DevTools shows the actual wire headers, unlike JavaScript.

If curl shows the headers

Nothing is wrong with Railway or your app, and the failure you are debugging is something else. Post the exact error text from the browser console: the CORS messages are specific, and "blocked by CORS policy: No Access-Control-Allow-Origin header is present" means something quite different from a credentials mismatch or a wildcard-with-credentials error.

If curl does not show them

Then it is app-side, and two causes account for most cases.

Ordering. app.use(cors()) has to be registered before the routes it should apply to. Registered afterwards it never runs for those paths, while still looking correct in the file.

Express 5 wildcards. If you are on Express 5, the bare string "" is no longer a valid route pattern. app.options("", cors()) either throws at startup or fails to match; the v5 form is app.options("/*splat", cors()). A lot of preflight handlers broke on exactly this during the v4 to v5 upgrade. If you rely on app.use(cors()) alone you do not need an explicit options route at all, since the middleware answers preflights itself.

For what it is worth, Railway's edge does not remove response headers on OPTIONS. A proxy that did would break essentially every browser app deployed on it, so the prior probability there is very low.


Welcome!

Sign in to your Railway account to join the conversation.

Loading...