The CORS problem in production
You've built a solid Go API that handles thousands of requests per second. Your frontend team deploys to a different domain, and suddenly browsers start blocking requests with cryptic CORS errors. CORS in Go APIs: concepts, trade-offs and production lessons teaches us that this isn't a bug—it's a security feature you need to configure deliberately. Getting CORS wrong means either locking out legitimate clients or exposing your API to any origin that decides to call it.
This is day 35 of our 100-day Go production series, and we're tackling one of the most misunderstood aspects of API security. CORS isn't just about adding headers; it's about understanding the threat model, choosing the right middleware, and avoiding the common pitfall of allowing all origins with a wildcard.
Understanding CORS and why it exists
Cross-Origin Resource Sharing (CORS) is a browser security mechanism that prevents a malicious website from making unauthorized requests on your behalf. When your frontend at app.example.com tries to call an API at api.example.com, the browser sees different origins and enforces CORS policy.
The browser sends a preflight request (an OPTIONS call) asking the server: "Is it okay if I send a request from this origin with these headers?" If the server doesn't say yes explicitly, the browser blocks the actual request. This protects users from attacks where a compromised site steals credentials or modifies data through your API.
The key insight: CORS is not authentication. A user's browser will still include cookies and auth tokens. CORS just controls which origins can make the request. You still need proper auth on every endpoint.
The rs/cors middleware approach
In Go, the rs/cors package is the industry standard for CORS handling. It's battle-tested across production systems and handles edge cases that naive implementations miss. Most teams we work with start by writing their own CORS logic and regret it later when subtle bugs surface.
Here's a minimal setup:
package main
import (
"net/http"
"github.com/rs/cors"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id": 1, "name": "Alice"}`))
})
handler := cors.Default().Handler(mux)
http.ListenAndServe(":8080", handler)
}cors.Default() uses sensible defaults: it allows any origin, any method, and any header. This is a starting point only. In production, you'll configure it explicitly.
The middleware integrates with any http.Handler, which means it works with Go's standard library, popular routers like Chi or Gin, and frameworks like Echo. This flexibility is why it's the go-to choice.
Exact allowed origins: the security boundary
Allowing any origin defeats the purpose of CORS. If you use AllowedOrigins: []string{"*"}, you're saying "let anyone call this API from any domain." In production systems we've seen, this comes from misunderstanding the threat model or rushing to ship.
The correct approach is to specify exactly which origins you trust:
c := cors.New(cors.Options{
AllowedOrigins: []string{
"https://app.example.com",
"https://admin.example.com",
"https://localhost:3000", // local development
},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
MaxAge: 3600,
})
handler := c.Handler(mux)Notice we specify HTTPS. That's not accidental—in production, always use HTTPS origins. HTTP-only origins are a security smell. We've also limited methods and headers to what our API actually needs.
The MaxAge: 3600 tells the browser to cache the preflight response for one hour. This reduces the preflight overhead from two requests per operation to one. It's a meaningful performance gain at scale.
For dynamic environments where you might have many frontends, consider loading allowed origins from configuration or a database:
allowedOrigins := os.Getenv("ALLOWED_ORIGINS") // "https://app.example.com,https://admin.example.com"
origins := strings.Split(allowedOrigins, ",")
c := cors.New(cors.Options{
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
})
handler := c.Handler(mux)This keeps your CORS configuration environment-specific without code changes. We recommend storing this in your secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) rather than committing it to source control.
Handling preflight OPTIONS requests
The browser sends an OPTIONS request before certain requests. This is the preflight. Your CORS middleware must respond to OPTIONS with the right headers and a 200 status code. If it returns an error or wrong headers, the browser will block the actual request.
The rs/cors middleware handles this automatically. You don't need to write a handler for OPTIONS. The middleware intercepts it, validates the origin, and sends the right response headers.
Here's what a preflight exchange looks like:
| Request (from browser) | Response (from server) |
| OPTIONS /api/users Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type |
200 OK Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Max-Age: 3600 |
If the preflight fails—for example, if the origin isn't in your allowed list—the server returns 200 OK but doesn't set the Access-Control-Allow-Origin header. The browser sees this missing header and blocks the actual request.
A common mistake is returning 404 or 405 for OPTIONS requests. This will break CORS even if your GET/POST handlers are correct. The middleware takes care of this, but if you're manually handling OPTIONS, ensure you respond with 200.
Credentials: explicit origins required
If your frontend sends cookies or authorization headers with every request, you need to tell the browser and server that this is allowed. This is where many teams get tangled up.
When your JavaScript code does:
fetch("https://api.example.com/api/users", {
credentials: "include" // send cookies
})The browser won't send cookies unless the server explicitly allows credentials from that origin. Setting AllowedOrigins: []string{"*"} is incompatible with credentials. The browser will reject it.
Here's the correct configuration:
c := cors.New(cors.Options{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
ExposedHeaders: []string{"X-Total-Count"}, // custom headers the frontend needs
AllowCredentials: true, // critical for cookies and auth
MaxAge: 3600,
})Setting AllowCredentials: true adds the header Access-Control-Allow-Credentials: true to responses. But it also enforces a strict rule: you must specify exact origins. No wildcards.
The ExposedHeaders field is easy to miss. By default, the browser only exposes standard headers to JavaScript. If your API returns custom headers like pagination info or rate limit status, you need to list them here so the frontend can read them.
A real example: you're returning a X-Total-Count header so the frontend knows how many items exist in a paginated list. Without adding it to ExposedHeaders, the frontend won't be able to read it even though the header is sent.
Common mistakes we've seen in production
- Wildcard with credentials. Setting both
AllowedOrigins: []string{"*"}andAllowCredentials: true. The browser will reject this. Choose one or the other. - Forgetting HTTPS in origins. Specifying
http://app.example.comin production. HTTP origins should only exist in local development. - Not exposing custom headers. Your API returns
X-RateLimit-Remainingbut forgot to add it toExposedHeaders. The frontend sees null. - Setting AllowedHeaders to ["*"]. This doesn't work. Be explicit about which headers your API needs. Typically
Authorization,Content-Type, and maybe a few custom headers. - Hardcoding origins in code. Next week someone pushes a new frontend to a new domain and CORS breaks. Load from environment or config.
- Not handling local development. Your staging config has one origin, but developers need localhost. Use environment-specific configs.
Environment-specific configurations
Production, staging, and local development need different CORS rules. Baking them into code doesn't scale.
func corsConfig() cors.Options {
env := os.Getenv("ENVIRONMENT")
baseConfig := cors.Options{
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
MaxAge: 3600,
}
switch env {
case "production":
baseConfig.AllowedOrigins = []string{
"https://app.example.com",
"https://admin.example.com",
}
baseConfig.AllowCredentials = true
case "staging":
baseConfig.AllowedOrigins = []string{
"https://app-staging.example.com",
"https://admin-staging.example.com",
}
baseConfig.AllowCredentials = true
case "development":
baseConfig.AllowedOrigins = []string{
"http://localhost:3000",
"http://localhost:3001",
}
baseConfig.AllowCredentials = true
}
return baseConfig
}This makes it clear what each environment allows and removes the guesswork from deployments.
Key takeaways
- CORS is a browser security mechanism, not an authentication mechanism. Use exact allowed origins to define your security boundary.
- Use the
rs/corsmiddleware for Go APIs. It handles edge cases and integrates cleanly with any HTTP handler. - Specify exact origins in production. Never use
AllowedOrigins: []string{"*"}unless you're building a public API with no credentials. - When using credentials (
AllowCredentials: true), you must specify exact origins. Wildcard is forbidden. - Remember
ExposedHeadersif your API returns custom headers. The browser blocks access to unlisted headers by default. - Load CORS configuration from environment variables or a config file, not from code. This keeps production secure and doesn't break local development.
- Preflight OPTIONS requests are handled automatically by
rs/cors. Don't return 404 or 405 for OPTIONS; that breaks CORS.






Comments
Loading comments…