The browser blocks your request before it even reaches your code
You write a Go HTTP server. You write a frontend in React or vanilla JavaScript. You call a fetch endpoint. The network tab shows a 200 OK, but the console screams about CORS. The browser intercepted the response and threw it away. Your Go code worked perfectly. The browser just refused to hand the data to your JavaScript.
This happens because browsers enforce a security boundary called an origin. An origin is a combination of scheme, host, and port. If your frontend runs on http://localhost:3000 and your Go API runs on http://localhost:8080, they are different origins. The browser treats them as separate security domains. It will not let JavaScript from one origin read responses from another unless the server explicitly grants permission.
What CORS actually is
CORS stands for Cross-Origin Resource Sharing. It is a browser security feature, not a server bug. Think of it like a building lobby with a reception desk. Every visitor (JavaScript running on your frontend) has a badge for a specific building (the origin). If the visitor tries to enter a different building (your Go API on a different domain or port), the reception desk checks the visitor's badge against the building's guest list. If the building has not explicitly said "allow visitors from that origin," the reception desk turns them away. The server still processes the request, but the browser never shows the result to your code.
The browser handles this by sending special HTTP headers. For simple requests, it adds an Origin header to the outgoing request. The server responds with Access-Control-Allow-Origin. If the values match, the browser hands the response to your JavaScript. If they do not match, the browser blocks it.
Complex requests trigger a preflight. The browser sends an OPTIONS request first to ask the server what methods and headers are allowed. The server must respond with Access-Control-Allow-Methods and Access-Control-Allow-Headers before the actual request is sent. If the preflight fails, the real request never leaves the browser.
The browser enforces the rule. Your server just answers the question.
The minimal wrapper
Go does not ship with a built-in CORS middleware. You write it by wrapping an http.Handler. The standard library uses a simple interface for routing and middleware:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Any type that implements ServeHTTP can be passed to http.ListenAndServe or chained with other handlers. Middleware works by creating a new handler that runs code before or after calling the next handler in the chain.
Here is a minimal CORS wrapper that handles simple requests and preflights:
// CORSHandler wraps an http.Handler and adds CORS headers to every response.
func CORSHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set the allowed origin. Replace * with a specific domain in production.
w.Header().Set("Access-Control-Allow-Origin", "*")
// Declare which HTTP methods the client is allowed to use.
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
// Declare which headers the client is allowed to send.
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Handle preflight requests immediately without forwarding to the next handler.
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Pass the request to the actual route handler.
next.ServeHTTP(w, r)
})
}
You attach it to your server by wrapping your root handler or individual routes:
// main starts the HTTP server with the CORS middleware attached.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status": "ok"}`))
})
// Wrap the entire multiplexer so every route gets CORS headers.
handler := CORSHandler(mux)
http.ListenAndServe(":8080", handler)
}
Headers must be set before the first byte of the body leaves the wire.
How the request flows through the wrapper
When a browser sends a request to your Go server, the http.Server receives it and calls ServeHTTP on your root handler. Because you wrapped the multiplexer with CORSHandler, the wrapper runs first.
The wrapper sets three headers on the http.ResponseWriter. These headers are buffered in memory. They do not send to the client until the response is flushed. The wrapper then checks the HTTP method. If the method is OPTIONS, the browser is performing a preflight check. The wrapper responds with a 204 No Content status and returns early. This tells the browser "yes, you can proceed with the actual request." The actual route handler never runs for preflights.
If the method is GET or POST, the wrapper calls next.ServeHTTP(w, r). The request flows down to your route handler. Your handler writes the JSON body. When your handler returns, the http.Server flushes the buffered headers and the body to the network. The browser receives the response, checks the Access-Control-Allow-Origin header against the Origin header it sent, and either delivers the data to your JavaScript or blocks it.
The http.ResponseWriter interface is deliberately simple. It only exposes Header(), Write(), and WriteHeader(). This design forces you to set headers before writing the body. If you call Write() first, the server automatically sends a 200 OK status and flushes the headers. Any subsequent Header().Set() calls are ignored because the HTTP response has already started.
A production-ready version
Using * for Access-Control-Allow-Origin works for public APIs, but it breaks when your frontend sends credentials like cookies or authorization headers. Browsers strictly forbid Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true is present. You must specify the exact origin.
Here is a version that validates the origin and supports credentials:
// StrictCORSHandler validates the request origin and adds secure CORS headers.
func StrictCORSHandler(allowedOrigins []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract the origin the browser is requesting.
origin := r.Header.Get("Origin")
// Check if the origin matches our allowed list.
allowed := false
for _, o := range allowedOrigins {
if o == origin {
allowed = true
break
}
}
// Only set CORS headers if the origin is explicitly allowed.
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
// Respond to preflight requests immediately.
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Forward to the next handler in the chain.
next.ServeHTTP(w, r)
})
}
You configure it with a slice of trusted domains:
// main starts the server with strict origin validation.
func main() {
allowed := []string{"http://localhost:3000", "https://myapp.example.com"}
handler := StrictCORSHandler(allowed, http.DefaultServeMux)
http.ListenAndServe(":8080", handler)
}
This pattern keeps your security policy explicit. If a request comes from an unlisted origin, the wrapper skips the CORS headers entirely. The browser receives a response without Access-Control-Allow-Origin and blocks it at the client side. Your server still processes the request, which is fine for logging or rate limiting, but the sensitive data never reaches unauthorized scripts.
CORS is a browser feature. The server only provides the answer key.
Common mistakes and compiler traps
The most frequent runtime error is calling WriteHeader or Write before setting CORS headers. The http.ResponseWriter buffers headers until the first write. If you write the body first, the headers flush automatically. Any Header().Set() call after that point silently fails. You will get a 200 OK response with no CORS headers, and the browser will block it. Always set headers at the top of your handler or middleware.
Another trap is forgetting to handle OPTIONS requests. If your router does not explicitly route OPTIONS to a handler, the standard library returns a 405 Method Not Allowed. The preflight fails, and the browser never sends the actual request. Your middleware must intercept OPTIONS and return 204 or 200 with the appropriate CORS headers before the router rejects it.
Type mismatches also cause friction. If you accidentally pass a string slice to a function expecting a single string, the compiler rejects the program with cannot use origins (variable of type []string) as string value in argument. Go's type system catches these mistakes at compile time, which saves you from runtime panics. If you forget to import net/http, you get undefined: http. If you import it but never use it, you get imported and not used. The compiler enforces clean dependencies.
Goroutine leaks rarely happen in simple HTTP middleware, but they do appear if you spawn background tasks that wait on channels without a cancellation path. Keep your middleware synchronous. Let the http.Server manage the connection lifecycle. If you need to run background work, pass a context.Context derived from r.Context() and respect its cancellation.
Trust the spec. Validate origins explicitly.
When to reach for CORS middleware
Use a custom CORS wrapper when you control the server and need precise origin validation without external dependencies. Use a third-party middleware package when you need comprehensive spec compliance, including complex header parsing and wildcard expansion. Use a reverse proxy like Nginx or Caddy when you want to offload header management from your application code and handle TLS termination at the edge. Skip CORS entirely when your frontend and backend share the exact same domain and port, because same-origin requests bypass the browser's security checks.