Integration · Go

CORS in Go: net/http, Gin, and Chi

Go's standard library has no equivalent of Express's cors middleware or Django's django-cors-headers: a plain net/http handler answers an OPTIONS preflight exactly like any other request, Origin header or not. We built a small server for each approach, net/http by hand, gin-contrib/cors 1.7.9, and go-chi/cors 1.2.2, and sent each one the same preflight to see what actually comes back.

· ~7 min read

What net/http does with nothing installed

A bare handler doesn't special-case OPTIONS. We pointed a minimal net/http server at a JSON route and sent a preflight:

curl -i -X OPTIONS http://127.0.0.1:8091/api/widgets \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET"

It came back 200 OK with the handler's own JSON body, the same response a real GET would get. No Access-Control-Allow-Origin, no Access-Control-Allow-Methods, nothing CORS-specific. curl shows a clean 200; a browser making the real request afterward reads that same response, finds no Access-Control-Allow-Origin header, and blocks it. This is the part that confuses people testing with curl: the preflight "succeeding" on the wire tells you nothing about whether the browser will accept the follow-up request.

Handling it yourself

Because the mechanics are just headers plus a short-circuit on OPTIONS, a hand-rolled middleware is a few lines:

func withCORS(allowedOrigin string, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		origin := r.Header.Get("Origin")
		if origin == allowedOrigin {
			w.Header().Set("Access-Control-Allow-Origin", origin)
			w.Header().Set("Access-Control-Allow-Credentials", "true")
			w.Header().Set("Vary", "Origin")
		}
		if r.Method == http.MethodOptions {
			w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
			w.Header().Set("Access-Control-Max-Age", "600")
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next(w, r)
	}
}

Wrapping the same handler with this and re-running the preflight from a matching origin got a real response this time:

HTTP/1.1 204 No Content
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Max-Age: 600
Vary: Origin

Sending the exact same preflight with Origin: https://evil.example.com got a 204 too, since nothing here rejects the request outright, but with no Access-Control-Allow-Origin header at all. That's the correct outcome: the preflight doesn't need to fail for the browser to block the real request afterward, it just needs to not carry a matching origin. Forgetting the Vary: Origin header is the easy mistake here: without it, a caching layer in front of the server can serve one origin's allowed response to a different origin.

gin-contrib/cors

For Gin (we tested against gin-gonic/gin 1.12.0), gin-contrib/cors 1.7.9 wraps the same idea in a config struct:

router := gin.New()
router.Use(cors.New(cors.Config{
	AllowOrigins:     []string{"https://app.example.com"},
	AllowMethods:     []string{"GET", "POST"},
	AllowHeaders:     []string{"Content-Type", "Authorization"},
	AllowCredentials: true,
	MaxAge:           10 * time.Minute,
}))
router.GET("/api/widgets", func(c *gin.Context) {
	c.JSON(200, gin.H{"status": "ok"})
})

The same preflight against this server came back 204 No Content with Access-Control-Allow-Methods: GET,POST, the full configured list, regardless of which method the preflight actually asked for in Access-Control-Request-Method. AllowOrigins is one of a few mutually exclusive options; the README is explicit that you set only one of AllowAllOrigins, AllowOrigins, AllowOriginFunc, or AllowOriginWithContextFunc, never two at once.

go-chi/cors

For chi (v5.5.0), go-chi/cors 1.2.2 takes an Options struct and a cors.Handler you register at the top of the router:

r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
	AllowedOrigins:   []string{"https://*.example.com"},
	AllowedMethods:   []string{"GET", "POST"},
	AllowedHeaders:   []string{"Content-Type", "Authorization"},
	AllowCredentials: true,
	MaxAge:           600,
}))
r.Get("/api/widgets", widgetsHandler)

Two things stood out against the exact same preflight. First, the response was 200 OK rather than Gin's 204; both are valid for a preflight, but if a test asserts on status code, don't assume it's 204 everywhere. Second, Access-Control-Allow-Methods only echoed back the single method the preflight asked for (Access-Control-Allow-Methods: POST for a POST preflight, GET for a GET one), not the full configured list the way gin-contrib/cors does. We also confirmed the https://*.example.com pattern matches https://app.example.com but not the bare https://example.com, and that the README's warning is real: putting the middleware inside r.Group() or behind r.With() instead of at the router's top level means OPTIONS requests never reach it unless you've also registered routes for that method.

The wildcard-plus-credentials mistake

All three setups above use a specific origin, not *, because AllowCredentials: true is on. That pairing is a hard browser rule, not a library quirk: a credentialed request is rejected outright if Access-Control-Allow-Origin is the literal string *, no matter what Access-Control-Allow-Credentials says. curl won't show you this; it happily prints the headers regardless of whether a real browser would honor them. gin-contrib/cors and go-chi/cors both let you set AllowOrigins: []string{"*"} and AllowCredentials: true at the same time without erroring at startup, so the mistake only shows up later, as a silent failure in the browser console, when someone adds a cookie-based call to a handler that used to be public. go-chi/cors's pattern matching (https://*.example.com) is a different thing from the literal wildcard: it still echoes back the specific matched origin in the response header, so it's safe to combine with credentials.

How the open-source Go relay does it

The open-source Go relay behind this site, a separate and much simpler program from the managed service, sets its CORS headers directly in net/http rather than pulling in a middleware package. Running it locally with ALLOWED_ORIGINS=https://app.example.com and sending the same preflight returns:

HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Max-Age: 86400

Two details worth knowing if you self-host it. First, it sends Access-Control-Allow-Credentials: true on every response whenever ALLOWED_ORIGINS isn't the bare wildcard, whether or not the caller is actually using cookies, so there's no separate flag to turn credentials off while still locking to a specific origin. Second, it forwards every request header except Host (including Cookie and Authorization) and passes every response header back (including Set-Cookie), so a target that sets a session cookie hands it straight through, scoped to whatever domain you run the relay on. That's a different tradeoff from the managed corsproxy.dev service, which strips Cookie and Authorization from the browser's request and strips Set-Cookie from the upstream's response by default. Self-host vs managed covers which one fits which job.

When the server isn't yours to change

Everything above assumes you control the Go service answering the request. When the API you're calling from the browser is someone else's and sends no CORS headers at all, adding middleware to your own server doesn't help, your frontend is talking directly to their origin. That's the actual use case for a CORS proxy: a man-in-the-middle with its own Access-Control-Allow-Origin header, not a replacement for setting up CORS correctly on a server you own.