Two CORS Gotchas in Gin That Wasted My Afternoon
I was building a Go backend using Gin for a hobby project (a multi-tenant SaaS platform) and hit two separate CORS bugs in the same afternoon. Neither was obvious from the error messages alone.
Gotcha 1: Trailing Slash Redirects Bypass CORS Middleware
The browser was throwing a CORS error on a specific endpoint, but only sometimes. I noticed this in the Gin debug logs:
[GIN-debug] redirecting request 301: /api/v1/organizations/:id/branches/ --> /api/v1/organizations/:id/branches
Gin has a default behavior called RedirectTrailingSlash. When it is enabled (which it is by default), Gin treats a request to /branches/ and /branches as two different routes. If you define the route without a trailing slash, Gin sends a 301 redirect to the canonical version.
The problem is that this 301 redirect happens at the router level, before the CORS middleware runs on the redirected request. So the redirected response does not have the Access-Control-Allow-Origin header, and the browser blocks it.
The fix is straightforward. Disable the redirect behavior and remove trailing slashes from your route definitions:
r := gin.Default()
r.RedirectTrailingSlash = false
And update the routes:
// Before
organization.POST("/", controller.CreateOrganization)
branch.GET("/", controller.GetBranches)
// After
organization.POST("", controller.CreateOrganization)
branch.GET("", controller.GetBranches)
Now the browser gets a clean response without a redirect, and the CORS middleware runs on every request.
Gotcha 2: Assigning AllowHeaders Wipes the Defaults
While setting up CORS config, I wanted to allow the Authorization header. I wrote:
config.AllowHeaders = []string{"Authorization"}
This caused the browser to complain about missing Content-Type and Content-Length headers in preflight responses. The reason is subtle: assigning to AllowHeaders replaces the default list entirely. Here is what cors.DefaultConfig() includes out of the box:
AllowHeaders: []string{"Origin", "Content-Length", "Content-Type"},
By assigning a new slice, I wiped those three defaults and only kept Authorization.
The fix is to append instead of assign:
config.AllowHeaders = append(config.AllowHeaders, "Authorization")
Putting It Together
Here is the full working CORS setup I ended up with:
func AddCors(r *gin.Engine) {
r.RedirectTrailingSlash = false
r.Use(cors.New(GetCorsConfig()))
}
func GetCorsConfig() cors.Config {
config := cors.DefaultConfig()
config.AllowOrigins = []string{
"http://localhost:3000",
"http://localhost:5173",
"https://admin.example.com",
}
config.AllowHeaders = append(config.AllowHeaders, "Authorization")
config.AllowCredentials = true
config.ExposeHeaders = []string{"Set-Cookie"}
return config
}
Both issues are easy to miss because the error messages just say “CORS” without pointing at the actual cause. If you are seeing inconsistent CORS failures in Gin, check your route definitions for trailing slashes first, then check whether you are assigning or appending to AllowHeaders.