skip to content

What does a Go CORS middleware write into Access-Control-Allow-Origin when several origins are allowed?

level: middleimportance: should knowfreq 48%

answer

  1. the header holds one value
  2. the allowlist cannot go in the header
  3. compare, then copy the request's value
  4. exact string: scheme, host and port
  5. the answer now depends on the request

basics

~20 s

Exactly one origin: the request's Origin header echoed verbatim, after checking it against the allowed set. The header takes a single value, never a comma-separated list, and the middleware adds Vary: Origin because the response now differs per caller.

solid answer

~40 s

`Access-Control-Allow-Origin` holds one value. With an allowlist you therefore *echo*: read `origin := r.Header.Get("Origin")`, look it up in the set loaded at startup — a `map[string]bool` or `map[string]struct{}`, so the check is a hash lookup rather than a loop — and if it is there, `w.Header().Set("Access-Control-Allow-Origin", origin)` with the exact string the browser sent, scheme, host and port included. Because the value now depends on the request, `w.Header().Add("Vary", "Origin")` goes with it. If the real requests carry cookies, also set `Access-Control-Allow-Credentials` to the literal `"true"`. Two guards matter: an empty `Origin` means this is not a browser cross-origin request, so write nothing; and echoing without the allowlist check turns the API into one that allows whoever asks.

code

go · 14 lines
go
// allowed is built once at startup from configuration
var allowed = map[string]bool{
	"https://app.example.com":   true,
	"https://admin.example.com": true,
}

origin := r.Header.Get("Origin")
if origin == "" || !allowed[origin] {
	next.ServeHTTP(w, r) // not a browser CORS request, or not permitted
	return
}
w.Header().Set("Access-Control-Allow-Origin", origin) // the exact string, never a list
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")

go deeper

for a junior

Remember that Access-Control-Allow-Origin holds one origin, and that with several allowed origins the server copies back the one the request sent rather than listing them all.

for a middle

Be able to write the branch: read the Origin header, look it up in a set built at startup, set the header to that exact string, and add Vary: Origin beside it.

for a senior

Point out the review-catchable failure — an echo with no lookup allows every origin and passes every browser test — and explain why the comparison must be exact string equality, not a prefix or suffix match.

for a principal

Decide where the allowed-origin list actually lives — service configuration, a shared library, or the edge — and be ready to justify the operational cost of adding a new frontend origin under that choice.

## One header, one value `Access-Control-Allow-Origin` is defined to carry a single origin, or the wildcard `*`. It is not a list header: writing `https://app.example.com, https://admin.example.com` into it does not allow two origins, it produces a value that matches neither and the browser blocks both. So a Go service that serves several browser origins cannot state its whole policy in one static header. It must decide per request. That is what "echoing the origin" means. ## The echo, with the check that makes it safe ```go origin := r.Header.Get("Origin") if origin != "" && allowed[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Add("Vary", "Origin") } ``` Three details carry the weight: **The set is built once.** Load the permitted origins at startup into a `map[string]bool` (or `map[string]struct{}` if you want the value to occupy nothing) and close over it in the wrapper. A map lookup is a hash of a short string; a linear scan over a slice on every request, or worse a prefix or suffix match, is both slower and the usual source of accidental matches. **The comparison is exact.** An origin is scheme + host + port, with no trailing slash and no path: `https://app.example.com` and `https://app.example.com:443` and `http://app.example.com` are three different strings, and browsers send whichever one the page was loaded from. String equality against a map key is the whole comparison — do not lowercase, trim or normalise creatively, because every transformation you invent is a way for an origin you did not mean to allow to compare equal. **An empty Origin is not a CORS request.** `Header.Get` returns `""` when the header is absent. A non-browser caller — another Go service, a command-line client, a health checker — sends no Origin at all, and neither does a plain same-origin page load. Writing `Access-Control-Allow-Origin: ` with an empty value in that case is meaningless at best; falling through with no CORS headers at all is correct, because a client that is not a browser is not applying the rule anyway. ## Why Vary: Origin travels with the echo Once the value of a response header is computed from a request header, the response is no longer the same for every caller. `Vary: Origin` is how the response says so. Use `Header().Add` rather than `Set`: `Vary` legitimately accumulates several field names, and another layer of your stack may already have contributed one that `Set` would discard. The practical consequence in Go code is that the `Vary` line belongs next to the `Set` that echoes the origin — same branch, same two lines every time — rather than being remembered separately later. ## What to do with an origin that is not on the list Write no `Access-Control-Allow-Origin` header. You do not need to fail the request: the browser is the enforcement point, and a response without the header simply cannot be read by page JavaScript from another origin. For a preflight, answering with a 2xx that lacks the header is enough for the browser to refuse to send the real request. Deliberately returning a 403 instead is a choice some services make for clarity in logs, but it changes nothing about what the browser permits. ## Credentials When the real request will carry cookies or an Authorization header supplied by the browser's credential machinery, the response must also carry `Access-Control-Allow-Credentials` with the literal string `"true"`: ```go w.Header().Set("Access-Control-Allow-Credentials", "true") ``` It is a string, not a Go `bool` — `strconv.FormatBool(true)` produces the same text but the literal is clearer. It must appear on the preflight response *and* on the real response, because the browser checks it on each. This is the other reason the echo exists: a service that answers with the wildcard cannot serve credentialed cross-origin requests at all, so any API a browser calls with cookies has to compute the exact origin per request. ## The failure this shape prevents The two ways hand-rolled Go CORS middleware goes wrong here are symmetrical. One is joining the allowlist into a single comma-separated value, which allows nobody and is discovered immediately. The other is echoing `r.Header.Get("Origin")` with no lookup at all, which allows everybody and is discovered by nobody, because every browser test passes. Any review of a CORS wrapper should find the line where the origin is compared against something before it is written back.

  • What should the middleware do when the Origin header is empty?
    Treat it as not a CORS request and write no Access-Control-Allow-* headers. A command-line client, another Go service or a health checker sends no Origin, and neither does a plain same-origin page load. Setting the header to an empty value serves nobody, and guarding on `origin != ""` before the map lookup keeps the wrapper from writing a header that means nothing.
  • Why is echoing r.Header.Get("Origin") without a lookup dangerous even though the code looks the same?
    Because the value the caller controls becomes the value the server asserts, so every origin that asks is allowed. It is invisible in testing: every browser you try works, which is exactly the symptom of allowing all of them. The lookup against the startup-loaded set is the only line that distinguishes a correct echo from an open one.
  • Where does Access-Control-Allow-Credentials have to appear?
    On both the preflight response and the response to the real request, as the literal string "true". The browser checks it separately at each step, so setting it only inside the preflight branch produces a preflight that passes followed by a real response the browser refuses to hand to JavaScript.

saying these in an interview costs you the question

  • Comma-joining the allowlist into one Allow-Origin value
  • Echoing whatever Origin arrives with no lookup
  • Setting the header when the Origin header is empty
  • Assuming the browser validates the origin for you
  • Omitting Vary: Origin when the value is per-request
  • Matching the origin by prefix or suffix instead of exactly