skip to content

Your Go CORS middleware answers the preflight with 204, but the browser still blocks the actual GET. Why?

level: seniorimportance: nice to knowfreq 30%

answer

  1. permission to send is not permission to read
  2. the browser checks twice
  3. look at the second response, not the preflight
  4. the branch that returns early got it right
  5. the fall-through path sets nothing

basics

~20 s

Almost always because the Access-Control-Allow-* headers are written only inside the preflight branch. The browser re-checks Access-Control-Allow-Origin on the real response, so every response the wrapper passes through, including 404s and 500s from inner handlers, has to carry it.

solid answer

~40 s

A successful preflight only buys permission to send the request; the browser checks `Access-Control-Allow-Origin` again on the response that comes back. The usual bug is a wrapper whose `if` branch sets the CORS headers, writes 204 and returns, while the fall-through path just calls `next.ServeHTTP` with nothing set. The fix is to set the origin headers unconditionally at the top of the wrapper, before the preflight test and before `next` runs. Two related traps: setting them *after* `next.ServeHTTP` returns is a no-op, because the inner handler has already written the status and `net/http` has serialised the header block by then; and error paths count — a 404 from the mux or a 500 from a handler with no `Access-Control-Allow-Origin` reaches the frontend as a CORS error, hiding the real status entirely.

code

go · 17 lines
go
// bug: CORS headers exist only on the preflight response
if r.Method == http.MethodOptions {
	w.Header().Set("Access-Control-Allow-Origin", origin)
	w.WriteHeader(http.StatusNoContent)
	return
}
next.ServeHTTP(w, r) // real GET, 404 and 500 all answer without any

// fix: set them first, so every response the chain produces carries them
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
	w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
	w.WriteHeader(http.StatusNoContent)
	return
}
next.ServeHTTP(w, r)

go deeper

for a junior

Remember that the browser checks Access-Control-Allow-Origin on the real response as well as on the preflight, so the header cannot live only in the OPTIONS branch.

for a middle

Explain why a response header set after the inner handler has run never reaches the client, and where in the wrapper the origin headers therefore belong.

for a senior

Work the diagnosis from the raw response rather than the console text, and call out that error responses without the header hide the real status from the frontend entirely.

for a principal

Own the convention that stops this recurring — CORS applied once around the whole chain rather than per route, with a test that asserts error responses carry the header.

## Two checks, not one A preflight is permission to *send*, not permission to *read*. When the real request goes out, the browser applies the same origin check to its response: if `Access-Control-Allow-Origin` is absent or does not match the page's origin, the response is discarded and the `fetch` promise rejects, no matter how good the preflight was. So a 204 preflight followed by a blocked GET is not a contradiction — it is the signature of a wrapper that treats CORS as a property of the preflight. ## The bug, in code ```go // buggy if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST") w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) // the real GET answers with no CORS headers at all ``` Everything inside the branch is right. The problem is what is *not* outside it. The corrected shape hoists the origin headers above the branch: ```go if origin != "" && allowed[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Add("Vary", "Origin") } if isPreflight { /* Allow-Methods, Allow-Headers, 204, return */ } next.ServeHTTP(w, r) ``` Only the preflight-specific headers — `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, `Access-Control-Max-Age` — belong inside the branch. `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials` and `Vary` belong on both. ## Why the headers must be set before next runs A tempting variant is to call `next.ServeHTTP(w, r)` and then set the header. It does not work. `w.Header()` returns a map that `net/http` reads once, when the response head is written — at the first `WriteHeader` or the first `Write` (which implies a 200). By the time the inner handler has returned, that has almost certainly already happened, and mutating the map afterwards changes nothing that reaches the wire. The rule generalises: anything a wrapper wants in the response headers has to be in the map before control passes inward. ## Error paths are the part people miss Even with the headers hoisted, there is a coverage question. Which responses actually pass through the wrapper? - A 404 written by the `http.ServeMux` because no pattern matched. - A 500 written by a handler that failed. - A 401 from a check that runs *inside* the wrapped chain. If the CORS wrapper is on the outside, all of these already carry the origin headers, because they were set before `next` ran. If it is applied per-route, or inside something that can answer first, the error responses escape it — and that produces the most confusing report in this whole area: the frontend sees a CORS error, the backend sees a 500 in its own logs, and the two teams argue about which system is broken. The browser reports the CORS failure because it will not surface a response it is not allowed to read, and the real status and body are unreadable from JavaScript. The frontend engineer cannot see the 500 at all. That is why the diagnostic that resolves this class of bug is not the console message but the raw response: look at the actual GET's response headers in the network panel, or reproduce the request from a terminal with the `Origin` header set. Either the `Access-Control-Allow-Origin` line is there or it is not, and if it is not you also get to see the real status the browser was hiding. ## The check to run on any hand-rolled CORS wrapper Three questions, in order: 1. Is `Access-Control-Allow-Origin` set on a path that does *not* return early? If it appears only inside the `OPTIONS` branch, the real response has none. 2. Is it set before `next.ServeHTTP`, not after? After is a no-op. 3. Does an error response from inside the chain still carry it? Fire a request that provokes a 500 and read the response headers, rather than assuming. A test makes the first two permanent: build a request with an `Origin` header, run the wrapper against a handler that writes a 500, and assert that the recorded response still has `Access-Control-Allow-Origin`. It is a few lines, and it catches the regression the next person will introduce when they tidy the branch.

  • Why does moving the header line to after next.ServeHTTP not fix it?
    Because net/http serialises the response header block at the first WriteHeader or Write call, which the inner handler has already made by the time it returns. Mutating the map from w.Header() afterwards changes an object nobody will read again. Anything a wrapper wants in the response headers must be in the map before control passes inward.
  • What does the frontend see when a 500 comes back without Access-Control-Allow-Origin?
    A CORS policy error and a rejected promise — not the 500. The browser refuses to surface a response the page is not allowed to read, so the status and body are invisible to JavaScript while the backend's own logs show the real failure. Two teams then debug two different problems, which is why the fix is to make error responses carry the header too.
  • How would you keep this from regressing?
    A small test: build a request carrying an Origin header, run the wrapper around a handler that writes a 500, record the response and assert that Access-Control-Allow-Origin is present. It pins the property that matters — every response out of the chain carries the header — rather than the shape of the code, so a later tidy-up of the preflight branch cannot silently undo it.

saying these in an interview costs you the question

  • Believing a successful preflight covers the real response
  • Setting CORS headers only inside the OPTIONS branch
  • Setting response headers after next.ServeHTTP returns
  • Leaving 404 and 500 responses without Allow-Origin
  • Blaming the browser cache for the blocked request