How to Implement CORS Middleware in Go

Web
Implement CORS in Go by creating a middleware handler that sets Access-Control headers and handles preflight OPTIONS requests.

The browser blocks your request, not the server

You build a sleek dashboard in React. You spin up your Go API on port 8080. You hit the endpoint from the browser, and nothing happens. The network tab shows a red error: "CORS policy blocked." The server returned data just fine. The Go handler executed, wrote the JSON, and closed the connection. But the browser threw the response away because it didn't trust the origin.

This happens because browsers enforce a security model that separates origins. Go's net/http package sends whatever you tell it to send. It has no concept of origins, security policies, or frontend frameworks. The browser does. You need to teach the Go server to speak the browser's security language by injecting specific headers into the response.

Origins and the security handshake

An origin is a combination of scheme, host, and port. http://localhost:3000 is a different origin from http://localhost:8080. Even http://localhost:8080 and https://localhost:8080 are different. By default, JavaScript running on one origin cannot read responses from another origin. This is the Same-Origin Policy.

Cross-Origin Resource Sharing (CORS) is the mechanism that relaxes this policy. When JavaScript makes a cross-origin request, the browser sends the request to the server. The server responds. Before JavaScript can read the response, the browser checks the response headers. If the headers explicitly allow the calling origin, the browser exposes the response. If the headers are missing or wrong, the browser blocks access.

CORS is a client-side enforcement. The server cannot prevent the request. The server can only respond with headers that tell the browser whether to allow the client to read the data. If a malicious script sends a request, the server still processes it. The browser just hides the result from the script.

Minimal middleware

Go does not include CORS middleware in the standard library. You write a middleware function that wraps an existing handler, sets the headers, and calls the next handler. The standard pattern returns an http.Handler that implements the ServeHTTP method.

Here's the simplest middleware. It wraps a handler and injects headers before the handler runs.

// corsMiddleware adds Access-Control headers to every response.
func corsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Allow any origin for development; restrict this in production.
		w.Header().Set("Access-Control-Allow-Origin", "*")
		// List the HTTP methods the client is allowed to use.
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		// List the headers the client is allowed to send.
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

		// Handle preflight requests immediately.
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		// Pass control to the next handler in the chain.
		next.ServeHTTP(w, r)
	})
}

Wrap your handler chain with this function.

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/data", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello from Go"))
	})

	// Wrap the mux with CORS middleware.
	handler := corsMiddleware(mux)
	http.ListenAndServe(":8080", handler)
}

When the browser makes a simple request, the middleware runs, sets the headers, and calls the handler. The response includes the headers. The browser sees Access-Control-Allow-Origin: *, which matches any origin, and allows JavaScript to read the body.

Middleware in Go is a chain of functions. Each function can inspect the request, modify the response, or short-circuit the chain. The http.Handler interface is the glue. Any function that returns http.Handler and takes http.Handler fits into this pattern. This is idiomatic Go composition. You wrap behavior around behavior.

Headers are promises. Keep them before you write.

Preflight requests and the OPTIONS trap

Not all requests are simple. A request is simple only if it uses GET, HEAD, or POST with a limited set of content types like application/x-www-form-urlencoded or text/plain. If the request uses a different method like DELETE or PUT, or includes custom headers like Authorization, the browser sends a preflight request first.

The preflight request uses the OPTIONS method. It asks the server: "Am I allowed to send a DELETE request with an Authorization header to this path?" The server must respond with headers that grant permission. The browser caches this result for a duration specified by Access-Control-Max-Age, or a default time if missing.

The middleware above handles preflight by checking r.Method == http.MethodOptions. If true, it writes a 204 No Content status and returns early. The handler never runs. The browser receives the 204 with the allow headers, checks them, and then sends the actual request. The middleware runs again for the actual request.

If you forget to handle OPTIONS, the server might return a 405 Method Not Allowed or a 200 OK without the CORS headers. The browser sees the missing headers and blocks the actual request. The network tab shows the preflight failed. The actual request never leaves the browser.

Always handle preflight in middleware. The handler should not know about CORS.

Production-ready CORS

The wildcard * is dangerous in production. It allows any site to read your data. If your API handles user sessions or sensitive data, you must restrict origins. Real apps maintain a whitelist of allowed origins and echo the specific origin back to the browser.

When the frontend uses cookies or authorization headers, the browser requires the server to set Access-Control-Allow-Credentials: true. The browser also requires the Access-Control-Allow-Origin header to match the request origin exactly. You cannot use * with credentials.

Additionally, if you echo the origin, you must set Vary: Origin. Proxies cache responses based on request headers. If the response varies by origin, the proxy must cache separate copies for each origin. Without Vary: Origin, a proxy might cache a response for one origin and serve it to another, leaking data or violating security constraints.

Here's a strict middleware that enforces these rules.

// strictCORS restricts access to a whitelist of origins and handles credentials.
func strictCORS(allowedOrigins []string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")

		// Only set Allow-Origin if the request origin is whitelisted.
		if contains(allowedOrigins, origin) {
			w.Header().Set("Access-Control-Allow-Origin", origin)
			// Required when the client sends cookies or auth headers.
			w.Header().Set("Access-Control-Allow-Credentials", "true")
			// Tell proxies to cache responses separately per origin.
			w.Header().Set("Vary", "Origin")
		}

		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		next.ServeHTTP(w, r)
	})
}

The helper function checks the whitelist.

// contains checks if a string exists in a slice.
func contains(slice []string, item string) bool {
	for _, s := range slice {
		if s == item {
			return true
		}
	}
	return false
}

This middleware echoes the origin only when it matches the whitelist. It sets credentials and varies the cache. It rejects requests from unknown origins by omitting Access-Control-Allow-Origin. The browser sees the missing header and blocks the response.

Echo the origin. Vary the cache. Trust nothing.

Pitfalls and runtime warnings

CORS bugs are subtle. The server often works fine in curl or Postman. The browser is the only thing that enforces CORS. If the headers are wrong, the browser blocks the response silently from JavaScript's perspective. You see the error in the browser console, not in the server logs.

One common mistake is combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. The browser rejects this combination. You must echo the origin. If you need credentials, drop the wildcard.

Another mistake is setting headers after writing the body. The ResponseWriter sends headers implicitly when you call Write or WriteHeader. Once headers are sent, you cannot change them. If the handler writes the body first, the middleware's header sets do nothing. The compiler does not catch this. The runtime just ignores the late header set. Always set headers before calling next.ServeHTTP.

If the handler calls w.WriteHeader after the middleware already called it, you get a runtime warning in logs: http: superfluous response.WriteHeader call from .... The first call wins. The second call is ignored. Middleware should call WriteHeader only for preflight responses. The handler should manage the status code for normal requests.

By convention, Go middleware follows the func(http.Handler) http.Handler signature. This matches standard library functions like http.TimeoutHandler and http.StripPrefix. Use this pattern so your middleware composes with the rest of the ecosystem. Run gofmt on your code. The formatter aligns the middleware chain and keeps the indentation consistent. Most editors run it on save.

The worst CORS bug is the one that works in development but fails in production because the origin whitelist is hardcoded.

When to use what

Use a simple wildcard middleware when building a public API or a development server where security restrictions are not required.

Use a strict origin-checking middleware when your API handles sensitive data or sessions and must restrict access to known frontend domains.

Use a third-party library like github.com/rs/cors when you need advanced features like origin parsing, max-age control, or debug logging without writing boilerplate.

Use no CORS middleware when your API is consumed only by server-side code or mobile apps that do not enforce browser security policies.

Where to go next