CORS
Cross-origin resource sharing is configured rather than installed. The zero value denies every cross-origin request, and no CORS middleware is installed at all until a policy names an origin or supplies a decision function.
That is the only safe default. A permissive policy set by accident hands any web page on the internet the ability to read authenticated responses from the browser of anyone visiting it.
Configuring it
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
CORS: muzak.CORSOptions{
AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Request-Id"},
ExposedHeaders: []string{"X-Request-Id", "RateLimit-Remaining"},
AllowCredentials: true,
MaxAge: 10 * time.Minute,
},
})
| Field | Default | Effect |
|---|---|---|
AllowedOrigins | none | The exact origins permitted, each written scheme://host[:port] in lower case, with no path and no default port. The single entry "*" allows any. A pattern and "null" are build errors |
AllowOriginFunc | none | A dynamic decision, consulted only for an origin AllowedOrigins did not already allow |
AllowedMethods | GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS | The methods a cross-origin request may use |
AllowedHeaders | Content-Type, Authorization, X-Request-Id | The request headers a client may send |
ExposedHeaders | none | The response headers a client may read. Browsers expose only a small safelist otherwise |
AllowCredentials | off | Permits cookies and Authorization headers on cross-origin requests |
MaxAge | 10 minutes | How long a browser may cache the preflight result. Browsers cap it regardless |
Origins are matched exactly, scheme and port included. https://app.example.com and
https://app.example.com:8443 are two different origins, and so are the http and https
spellings of the same host.
Because an entry is compared byte for byte with the Origin header a browser writes, an entry
spelled any other way would allow nobody, so it is refused when the application is built, with
the spelling to use instead:
- a trailing slash, as in
https://app.example.com/, or any other path, query or fragment - upper case in the scheme or host, as in
HTTPS://App.example.com - the scheme's default port, as in
https://app.example.com:443orhttp://app.example.com:80 - a pattern such as
*.example.com, which belongs inAllowOriginFunc - an entry with no scheme, such as
app.example.com
"null" is refused for the opposite reason: it matches far more than it appears to. It is the
origin a sandboxed iframe, a page loaded from a file: URL and a request redirected across
origins all send, so any page can arrange to present it, and listing it would let every one of
them read the response. Every entry that fails is reported together, joined with the other
build errors.
The combination that is refused
A wildcard origin combined with credentials is refused as a configuration error rather than served.
// This does not start. muzak.ErrCORSWildcardCredentials is reported when the
// application is built.
muzak.CORSOptions{
AllowedOrigins: []string{"*"},
AllowCredentials: true,
}
Browsers reject that pairing anyway, so accepting it here would only hide the mistake until it reached a browser and then look like a bug in the framework.
Deciding dynamically
muzak.CORSOptions{
AllowOriginFunc: func(origin string) bool {
return strings.HasPrefix(origin, "https://") && strings.HasSuffix(origin, ".example.com")
},
AllowCredentials: true,
}
It is consulted only when AllowedOrigins does not already allow the origin, and it runs on
every cross-origin request, so it must be cheap and free of side effects.
Be careful with suffix matching. strings.HasSuffix(origin, "example.com") without the dot
also matches https://notexample.com. Prefer an exact list, or parse the origin and compare
the host.
Exposing response headers
A browser lets a script read only a small safelist of response headers unless the policy says otherwise. If your clients read the request identifier or the rate limit budget, name those headers:
ExposedHeaders: []string{
muzak.HeaderRequestID,
muzak.HeaderRateLimitLimit,
muzak.HeaderRateLimitRemaining,
muzak.HeaderRateLimitReset,
},
A browser-based MCP client, such as an inspector served from another origin, sends headers
of its own (the session and protocol version, and on the 2026-07-28 revision the method and
tool name) and has to read the session identifier initialize answers with, which a browser
hides from it unless the policy exposes it:
AllowedHeaders: []string{"Content-Type", "Authorization",
muzak.HeaderMCPSessionID, muzak.HeaderMCPProtocolVersion,
muzak.HeaderMCPMethod, muzak.HeaderMCPName},
ExposedHeaders: []string{muzak.HeaderMCPSessionID},
The endpoint also checks the Origin itself, against MCPOptions.AllowedOrigins, so such a
client's origin is listed in both places. See MCP Tools.
Where CORS sits in the chain
CORS runs before any middleware installed with App.Use, and in front of the documentation
routes and the router, so it covers the API, /openapi.json and any documentation page alike.
RequestID -> tracing and Observer* -> SecurityHeaders -> Recovery -> health probes* -> Locale*
-> AccessLog -> AllowedHosts and RedirectHTTPS* -> CORS -> cross-origin check*
-> your middleware -> /docs and /openapi.json -> routes and mounts
The pieces marked * are there only when they are configured: AppOptions.Tracing or
Observer, Health, I18n, AllowedHosts or RedirectHTTPS, and CrossOriginProtection.
A health probe, and a request that AllowedHosts refuses or RedirectHTTPS redirects, are
answered before the policy runs and carry no CORS headers. The cross-origin check runs inside
it, so a refusal sent to an origin the policy allows carries the headers that let the page read
why.
The order matters for a browser. A preflight carries no credentials, so an authenticating
middleware that ran in front of the policy refused every preflight, and its own 401 or 429
carried no Access-Control-Allow-Origin, which a browser reports as a network error rather than
the status. Now a preflight is answered by the policy and never reaches a Use middleware, and a
response such a middleware writes itself carries the policy's headers for an allowed origin and
none for a denied one. A middleware that relied on seeing an OPTIONS preflight, to log it or to
answer CORS itself, no longer does when AppOptions.CORS names origins. Without a policy nothing
changes.
A preflight OPTIONS is answered by the CORS middleware. Routes also answer OPTIONS
automatically with an Allow header when nothing else does, which is a different mechanism
for a different question: Allow says which methods a path serves, and the CORS headers say
which a cross-origin caller may use.
Caches
Under a policy that names origins, every response carries Vary: Origin, whether or not the
origin was allowed. It used to be added only when the origin was allowed, so a shared cache could
serve the allowed origin a response without CORS headers. Preflight responses also vary on
Access-Control-Request-Method and Access-Control-Request-Headers. These are recorded on the
response and merged into Vary when it is written, so a handler that sets its own Vary adds to
them rather than erasing them.
A wildcard policy sends Access-Control-Allow-Origin: *, and Access-Control-Expose-Headers
when it names any, on every response, including one to a request with no Origin. Its answer
is then the same whatever the request says, which is why it needs no Vary: Origin. Before
0.2.9 those headers went only to a request carrying an Origin, so a browser or shared cache
that kept the answer to a plain navigation, a Static or Frontend file with its
Last-Modified above all, handed it to a later cross-origin fetch, which the browser refused.
A policy that cannot be served is reported with the other build errors, where it used to be
recorded after the start-up log had reported the routes registered, so every request was answered
with a bare 500 and the log named no reason.
What CORS does not cover
A WebSocket handshake. It is not subject to the same-origin policy and is never
preflighted, which is what makes cross-site hijacking possible in the first place.
AppOptions.CORS has no bearing on it. The separate check is
WSOptions.AllowedOrigins, and a cross-origin handshake is refused until it names one. See
WebSockets.
An event stream is covered, because an EventSource is an ordinary request subject to
the same-origin policy and to CORS like any other. See
Server-Sent Events.
A server-to-server client. CORS is a browser mechanism. curl, a Go client and a
mobile app ignore it entirely, so it is not an access control: it decides what a browser
will let a page read, and nothing more. Authorization is a
guard or a provider.
Cross-site request forgery. CORS decides what a page may read, not what it may send. A
form a page elsewhere submits, or a fetch it makes without a preflight, still reaches the
handler with the user's cookies attached; only the answer is withheld. Refusing that request
is AppOptions.CrossOriginProtection, which refuses a state-changing request a browser says
came from another origin, and which sessions require. An origin CORS allows to read is not
thereby allowed to change state: a frontend on another origin is listed in
CrossOriginOptions.TrustedOrigins as well. See
Cross-Origin Requests and
Sessions.
Testing it
func TestCORSAllowsTheApp(t *testing.T) {
client := testclient.New(t, buildApp())
res := client.Options("/items/",
testclient.Header("Origin", "https://app.example.com"),
testclient.Header("Access-Control-Request-Method", "POST"))
res.AssertHeader("Access-Control-Allow-Origin", "https://app.example.com")
}
func TestCORSDeniesAnyoneElse(t *testing.T) {
client := testclient.New(t, buildApp())
res := client.Get("/items/", testclient.Header("Origin", "https://evil.example"))
if got := res.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want no header at all", got)
}
}
Where to go next
Safe Defaults covers the rest of what is denied until it is configured, and TLS covers serving the origins above over HTTPS.