A preflight to your Go API fails once the SPA sends an Authorization header, though Allow-Origin and -Methods are set. Why?
answer
- the request announced something new
- compare what was asked against what was allowed
- one response header covers request headers
- the browser lists names before sending them
- Access-Control-Allow-Headers must cover all of them
basics
~20 sThe browser names that header in Access-Control-Request-Headers on the preflight, and the response never lists it in Access-Control-Allow-Headers, so the preflight fails and the real request is never sent. Name Authorization there, or echo what was requested.
solid answer
~40 sI read the raw preflight exchange rather than the console text: the OPTIONS request carries `Access-Control-Request-Headers: authorization` and the 204 response has `Access-Control-Allow-Origin` and `Access-Control-Allow-Methods` but no `Access-Control-Allow-Headers`. A preflight only succeeds if every header the browser announced is covered there, so the browser stops before sending the real request — which is why the server logs show the OPTIONS and nothing else. The fix is one line in the preflight branch: `w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")`. If the set of headers is open-ended I echo instead — copy `r.Header.Get("Access-Control-Request-Headers")` into the response and `Add` `Vary: Access-Control-Request-Headers` — which is acceptable only behind a strict origin allowlist, since it grants whatever the caller names. `Access-Control-Max-Age` then keeps the browser from repeating the preflight on every call.
code
go · 9 lines// explicit list — preferred, because a new header is a deliberate change
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// or echo what the browser announced, only behind a strict origin allowlist
if reqHdrs := r.Header.Get("Access-Control-Request-Headers"); reqHdrs != "" {
w.Header().Set("Access-Control-Allow-Headers", reqHdrs)
w.Header().Add("Vary", "Access-Control-Request-Headers")
}
w.Header().Set("Access-Control-Max-Age", "600")go deeper
Know that a browser announces the headers its real request will send, and that the server has to name them back in Access-Control-Allow-Headers or the call never leaves the browser.
Explain why adding an Authorization or custom header turns a request that needed no preflight into one that does, and write the response header that satisfies it.
Demonstrate the diagnosis: read the raw OPTIONS exchange, line the Access-Control-Request-* headers up against the Access-Control-Allow-* ones, and explain why a missing real request in the logs is evidence rather than a mystery.
Decide whether the allowed header set is stated explicitly in the service or reflected from the caller, and be ready to defend that choice against the frontend team's wish to add headers without a server change.
## Reading the failure instead of guessing at it The report arrives from a frontend engineer: "CORS error after we added the auth header." The console message names a policy, not a cause, so the first move is to look at the two messages themselves — the preflight request and its response — either in the network panel with "OPTIONS" selected, or by reproducing it from a terminal: ``` OPTIONS /v1/orders HTTP/1.1 Origin: https://app.example.com Access-Control-Request-Method: GET Access-Control-Request-Headers: authorization ``` and the answer your Go middleware wrote: ``` HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, DELETE ``` The diagnosis is in the absence: the browser announced a header, and the response never acknowledged it. The rule the browser applies is all-or-nothing — *every* name in `Access-Control-Request-Headers` must be covered by `Access-Control-Allow-Headers`, or the preflight fails and the real request is never sent. That last part explains the second symptom people bring to this: the server logs show an OPTIONS and no GET, so it looks as if the frontend stopped calling. It did — the browser stopped it. ## Why adding a header changed anything at all The browser preflights a request when it is not one of the plainly safe ones. Sending an `Authorization` header, or a custom `X-...` header, or a `Content-Type` outside the small safe set, is enough to trigger it. So a request that previously went straight through with only `Access-Control-Allow-Origin` needed now needs a preflight to succeed first, and the preflight has requirements the simple response never had to meet. Nothing about the origin configuration changed; the class of request did. ## The two shapes of the fix in Go **A fixed list.** If you know what your API accepts, name it: ```go w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id") ``` This is the version to prefer. It is explicit, it is reviewable, and when the frontend adds a header the failure is a one-line change with a deliberate decision behind it. **Echoing the request.** If the list is genuinely open-ended: ```go if reqHdrs := r.Header.Get("Access-Control-Request-Headers"); reqHdrs != "" { w.Header().Set("Access-Control-Allow-Headers", reqHdrs) w.Header().Add("Vary", "Access-Control-Request-Headers") } ``` The browser sends the names comma-separated and lowercased, and the response may repeat them exactly as received — there is no need to canonicalise, and the comparison the browser makes is case-insensitive. The `Vary` line is the same discipline as with `Origin`: the response is now computed from a request header. The tradeoff is that this allows whatever the caller names, so it only belongs behind an origin allowlist that has already decided who may ask. ## What this response header does not do `Access-Control-Allow-Headers` is about the **request**. It says nothing about which response headers page JavaScript may read. That is `Access-Control-Expose-Headers`, and the confusion between them produces a distinct bug report: the frontend can see `X-Request-Id` in devtools but `response.headers.get(...)` returns null. The devtools panel shows what arrived on the wire; the JavaScript API shows only what was exposed. Adding the name to `Access-Control-Expose-Headers` on the real response fixes that one; adding it to `Allow-Headers` does nothing for it. ## Cutting the cost once it works Every preflight is a round trip the user pays for before the real call. `Access-Control-Max-Age` lets the browser reuse the answer for a period without asking again: ```go w.Header().Set("Access-Control-Max-Age", "600") ``` Browsers cap the value well below whatever large number you write, so treat it as a hint rather than a contract, and remember that a cached preflight answer means a policy change does not take effect for those clients immediately. ## The wider habit When a browser call fails and a server-side call to the same endpoint succeeds, the difference is almost always in the preflight, and the preflight is a plain HTTP exchange you can print. Compare the `Access-Control-Request-*` headers on the way in against the `Access-Control-Allow-*` headers on the way out, field by field: method against `Allow-Methods`, each requested header name against `Allow-Headers`, credentials against `Allow-Credentials`, and origin against `Allow-Origin`. Whichever pair does not line up is the bug, and the console text will not tell you which one it was.
- The frontend sees X-Request-Id in devtools but JavaScript cannot read it. What is missing?Access-Control-Expose-Headers on the real response, naming X-Request-Id. Devtools shows everything that arrived on the wire, while the page's JavaScript can only read the small default set plus whatever the server exposes. Listing the header in Access-Control-Allow-Headers does nothing here — that header governs the request direction, not the response.
- Why do the server logs show the OPTIONS request and no GET at all?Because the browser never sent the GET. A failed preflight ends the exchange: the real request is not issued, so there is nothing to log and nothing to trace. That asymmetry is itself the diagnostic — an OPTIONS with no matching real request in the same window means the preflight response did not satisfy the browser.
- What is the risk of echoing Access-Control-Request-Headers back instead of listing them?It allows whatever the caller names, so the header policy is decided by the client rather than the server. That is tolerable only when the origin allowlist has already restricted who can get a successful preflight at all. With a permissive or echoed origin it removes the last constraint, and it also hides from review which headers the API actually expects.
saying these in an interview costs you the question
- Adding the header name to Access-Control-Allow-Methods instead
- Assuming the origin configuration must be wrong
- Not reading the raw OPTIONS request and response
- Confusing Allow-Headers with Expose-Headers
- Claiming the frontend stopped sending the request