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,
    },
})
FieldDefaultEffect
AllowedOriginsnoneThe 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
AllowOriginFuncnoneA dynamic decision, consulted only for an origin AllowedOrigins did not already allow
AllowedMethodsGET, HEAD, POST, PUT, PATCH, DELETE, OPTIONSThe methods a cross-origin request may use
AllowedHeadersContent-Type, Authorization, X-Request-IdThe request headers a client may send
ExposedHeadersnoneThe response headers a client may read. Browsers expose only a small safelist otherwise
AllowCredentialsoffPermits cookies and Authorization headers on cross-origin requests
MaxAge10 minutesHow 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:443 or http://app.example.com:80
  • a pattern such as *.example.com, which belongs in AllowOriginFunc
  • 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.