Building a Custom CORS Middleware in Go Web Servers
Daniel Hayes
Full-Stack Engineer · Leapcell

Introduction
In the modern web, it's increasingly common for web applications to interact with resources hosted on different domains. Think about a frontend application served from app.example.com making API calls to a backend service at api.example.com, or integrating with third-party services. This common scenario immediately brings up a fundamental security mechanism enforced by web browsers: the Same-Origin Policy. This policy, while crucial for security, can prevent legitimate cross-origin interactions, leading to frustrating "blocked by CORS" errors. To overcome this, the Cross-Origin Resource Sharing (CORS) mechanism was introduced, allowing servers to explicitly grant permission for cross-origin requests. While many Go web frameworks offer built-in CORS solutions, understanding and manually implementing CORS middleware provides invaluable insight into its workings, offers superior flexibility, and is essential for tailoring security policies precisely to your application's needs. This article will guide you through the process of manually implementing and configuring CORS middleware in a Go web server.
Understanding and Implementing Custom CORS Middleware
Before diving into the code, let's define some core concepts related to CORS that are vital for our discussion.
Core Terminology
- Same-Origin Policy (SOP): A fundamental security concept that restricts how a document or script loaded from one origin can interact with a resource from another origin. An origin is defined by the protocol, host, and port of the URL.
- Cross-Origin Request: Any request made to a resource whose origin is different from the origin of the requesting document.
- CORS (Cross-Origin Resource Sharing): A set of HTTP headers that web browsers and servers use to determine whether to allow a web page to make cross-origin requests.
- Preflight Request: For certain "complex" requests (e.g., HTTP methods other than
GET,HEAD,POSTwith simple content types, or requests with custom headers), browsers send an automatic "preflight"OPTIONSrequest to the server before the actual request. This preflight checks what CORS policies are permitted by the server for the subsequent actual request. - CORS Headers:
Access-Control-Allow-Origin: Indicates which origins are allowed to access the resource. Can be a specific origin or*for any origin.Access-Control-Allow-Methods: Specifies the HTTP methods allowed for cross-origin requests (e.g.,GET,POST,PUT,DELETE).Access-Control-Allow-Headers: Lists the HTTP headers that can be used in the actual request.Access-Control-Allow-Credentials: Indicates whether the browser should send credentials (cookies, HTTP authentication) with the cross-origin request. Must be set totrueif supported.Access-Control-Expose-Headers: Allows the server to whitelist headers that browsers are allowed to expose to the frontend JavaScript code.Access-Control-Max-Age: Indicates how long the results of a preflight request can be cached.
The Principle of CORS Middleware
A CORS middleware conceptually sits between the incoming HTTP request and your application's handlers. Its primary responsibility is to inspect the request, particularly the Origin header, and based on predefined rules, add appropriate CORS headers to the response. It also needs to explicitly handle OPTIONS preflight requests.
Implementing the Custom CORS Middleware
Let's build a flexible CORS middleware in Go. We'll define a configuration struct to manage our allowed origins, methods, and headers.
package main import ( "log" "net/http" "strings" "time" ) // CORSConfig holds the configuration for our CORS middleware. type CORSConfig struct { AllowedOrigins []string AllowedMethods []string AllowedHeaders []string ExposedHeaders []string AllowCredentials bool MaxAge time.Duration // Duration for Access-Control-Max-Age header } // NewCORSConfig creates a default CORS configuration. func NewCORSConfig() *CORSConfig { return &CORSConfig{ AllowedOrigins: []string{"*"}, // Allow all origins by default (be cautious in production) AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization"}, ExposedHeaders: []string{}, AllowCredentials: false, MaxAge: 10 * time.Minute, } } // CORSMiddleware is an http.Handler that wraps another http.Handler to provide CORS functionality. func CORSMiddleware(config *CORSConfig, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin == "" { // Not a CORS request, pass through next.ServeHTTP(w, r) return } // Check if the origin is allowed isOriginAllowed := false if len(config.AllowedOrigins) == 1 && config.AllowedOrigins[0] == "*" { isOriginAllowed = true // All origins allowed } else { for _, allowed := range config.AllowedOrigins { if allowed == origin { isOriginAllowed = true break } } } if !isOriginAllowed { // If origin not allowed, don't add CORS headers and deny the request log.Printf("CORS: Origin '%s' not allowed.", origin) w.WriteHeader(http.StatusForbidden) return } // Add Access-Control-Allow-Origin header if len(config.AllowedOrigins) == 1 && config.AllowedOrigins[0] == "*" { w.Header().Set("Access-Control-Allow-Origin", "*") } else { w.Header().Set("Access-Control-Allow-Origin", origin) } // If credentials are allowed, set the header if config.AllowCredentials { w.Header().Set("Access-Control-Allow-Credentials", "true") } // Handle preflight OPTIONS requests if r.Method == http.MethodOptions { // Add Access-Control-Allow-Methods header w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", ")) // Add Access-Control-Allow-Headers header based on requested headers requestedHeaders := r.Header.Get("Access-Control-Request-Headers") if requestedHeaders != "" { allowedRequestHeaders := make([]string, 0) for _, reqHeader := range strings.Split(requestedHeaders, ",") { reqHeader = strings.TrimSpace(reqHeader) for _, allowedConfigHeader := range config.AllowedHeaders { if strings.EqualFold(reqHeader, allowedConfigHeader) { allowedRequestHeaders = append(allowedRequestHeaders, reqHeader) break } } } if len(allowedRequestHeaders) > 0 { w.Header().Set("Access-Control-Allow-Headers", strings.Join(allowedRequestHeaders, ", ")) } else { // If no requested headers are explicitly allowed, respond with no headers (browser will deny) log.Printf("CORS: No requested headers allowed for origin '%s'. Requested: %s", origin, requestedHeaders) w.WriteHeader(http.StatusForbidden) return } } else { // If no specific headers are requested, just send configured allowed headers w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", ")) } // Set Access-Control-Max-Age if config.MaxAge > 0 { w.Header().Set("Access-Control-Max-Age", string(config.MaxAge/time.Second)) } // Respond to preflight with 204 No Content w.WriteHeader(http.StatusNoContent) return } // For actual requests, set exposed headers if configured if len(config.ExposedHeaders) > 0 { w.Header().Set("Access-Control-Expose-Headers", strings.Join(config.ExposedHeaders, ", ")) } // Pass the request to the next handler next.ServeHTTP(w, r) }) }
Explanation of the CORS Middleware Logic:
- Request Origin Check: It first checks for the
Originheader. If absent, it's not a cross-origin request, and processing continues to the next handler without CORS headers. - Allowed Origin Verification: It compares the
Originheader against theconfig.AllowedOrigins. If*is specified, all origins are allowed. Otherwise, it verifies if the specific origin is in the allowed list. If not, the request is denied withhttp.StatusForbidden. Access-Control-Allow-Origin: This header is set with either the specific allowedOriginfrom the request or*, depending on configuration.Access-Control-Allow-Credentials: Ifconfig.AllowCredentialsistrue, this header is added. Note thatAccess-Control-Allow-Origin: *cannot be used withAccess-Control-Allow-Credentials: true. Our currentAccess-Control-Allow-Originlogic handles this by setting the specificoriginif credentials are allowed.- Preflight
OPTIONSRequests: If the request method isOPTIONS:- It sets
Access-Control-Allow-Methodsbased onconfig.AllowedMethods. - It reads
Access-Control-Request-Headersfrom the client. It then checks if each requested header is inconfig.AllowedHeadersand constructs theAccess-Control-Allow-Headersresponse header only with the allowed requested headers. If custom headers are requested but not allowed, the preflight fails. Access-Control-Max-Ageis set based onconfig.MaxAge.- The middleware responds with
http.StatusNoContent(204) and terminates the request, as a preflight request doesn't expect a body.
- It sets
- Actual Requests: For any other HTTP method (actual requests),
Access-Control-Expose-Headersis set if configured, allowing client-side JavaScript to access specific response headers. Then the request is passed to thenexthandler for actual business logic.
Integrating the Middleware into a Go Server
Here's an example of how to use this CORSMiddleware with actual API handlers.
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) // MyHandler is a simple handler for demonstration. func MyHandler(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Hello from Go API!"}) return } if r.Method == http.MethodPost { var data map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&data); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } log.Printf("Received POST data: %+v", data) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "received", "data": fmt.Sprintf("%+v", data)}) return } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } func main() { // Configure CORS corsConfig := NewCORSConfig() corsConfig.AllowedOrigins = []string{ "http://localhost:3000", // Example frontend origin "http://127.0.0.1:3000", } corsConfig.AllowedMethods = []string{"GET", "POST", "PUT", "DELETE"} corsConfig.AllowedHeaders = []string{"Content-Type", "Authorization"} // Allow custom Authorization header corsConfig.AllowCredentials = true // Allow cookies/auth headers corsConfig.MaxAge = 1 * time.Hour // Cache preflights for 1 hour // Create a new ServeMux mux := http.NewServeMux() // Apply the CORS middleware to your handler // The order matters: CORS middleware should wrap your actual handler. corsHandler := CORSMiddleware(corsConfig, http.HandlerFunc(MyHandler)) mux.Handle("/api/data", corsHandler) // Another handler without CORS middleware (for demonstration of selective application) mux.HandleFunc("/public", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "This is a public endpoint, no CORS applied here.") }) log.Println("Server starting on port 8080...") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatalf("Server failed: %v", err) } }
In the main function:
- We initialize a
CORSConfigand customize it. For production,AllowedOriginsshould be specific to your frontend domains, not*.AllowCredentialsshould be set totrueif your frontend needs to send cookies or HTTP authentication headers in cross-origin requests. - We create a
mux(router). - We wrap our
MyHandlerwith theCORSMiddlewareusing our custom configuration. - We register the
corsHandlerto handle requests to/api/data. This means any request to/api/datawill first pass through our CORS logic.
Application Scenarios
- Single-Page Applications (SPAs): A common scenario where frontend (e.g., React, Angular, Vue) runs on one domain and fetches data from a separate backend API.
- Microservices: When services communicate across different domains or ports within a larger distributed system.
- Third-Party API Integrations: If your frontend calls an API on a different domain, that API's server needs to implement CORS. Conversely, if your API is consumed by third parties, you'd need this.
- Development Environments: Often, development servers run on different ports than the dev backend, necessitating CORS.
Conclusion
Manually implementing and configuring CORS middleware in a Go web server offers fine-grained control over cross-origin resource sharing, a critical aspect of web security and functionality. By understanding the core concepts of CORS—like preflight requests and specific HTTP headers—and carefully crafting a middleware, developers can ensure their applications communicate securely and effectively across different origins. This approach provides flexibility and deepens understanding, empowering you to address complex cross-origin challenges tailored to your application's unique requirements, ensuring robust and secure web interactions.

