I’ve seen a lot of Go APIs ship with one of two CORS setups:

  1. AllowOrigins: ["*"] and a prayer
  2. no CORS config at all, followed by frontend people yelling in Slack

Both work fine right up until browsers get involved.

This case study is based on a pretty typical setup: a Go backend serving JSON to a separate frontend app, first on localhost, then across staging and production domains. The backend started on Gin, another service used Echo, and both had the same problem: “it works in curl” but fails in the browser.

The setup

We had:

  • frontend: https://app.example.com
  • admin frontend: https://admin.example.com
  • API: https://api.example.com

Locally:

  • frontend: http://localhost:3000
  • API: http://localhost:8080

The frontend was sending:

  • Authorization: Bearer ...
  • Content-Type: application/json
  • occasional X-Request-ID

That combination matters, because it triggers preflight requests in many cases.

The first bug report sounded familiar:

Login works in Postman. Browser says CORS policy blocked the request.

That almost always means the server either:

  • didn’t answer the OPTIONS preflight correctly
  • forgot to allow a custom header
  • tried to combine credentials with *
  • exposed too few response headers for the frontend to read

Before: the “works on my machine” Gin config

Here’s the kind of Gin setup I keep seeing:

package main

import (
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.POST("/login", loginHandler)
	r.GET("/me", meHandler)

	r.Run(":8080")
}

No CORS middleware at all. That means browser requests from another origin fail immediately.

The second version is usually this:

package main

import (
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"time"
)

func main() {
	r := gin.Default()

	r.Use(cors.New(cors.Config{
		AllowOrigins: []string{"*"},
		AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowHeaders: []string{"*"},
		MaxAge: 12 * time.Hour,
	}))

	r.POST("/login", loginHandler)
	r.GET("/me", meHandler)

	r.Run(":8080")
}

This looks generous, but it broke as soon as we needed cookie-based auth in one flow and wanted the browser to read a pagination header in another.

A wildcard setup is blunt. It’s okay for some public APIs, but most app backends need more control.

For comparison, some public APIs really do intentionally return a wildcard. GitHub’s API does:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That makes sense for a broadly consumable API. It usually does not make sense for your authenticated app backend.

The actual production failure

The frontend called:

fetch("https://api.example.com/me", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${token}`,
    "X-Request-ID": crypto.randomUUID()
  }
})

Browser behavior:

  1. send OPTIONS /me
  2. include:
    • Origin: https://app.example.com
    • Access-Control-Request-Method: GET
    • Access-Control-Request-Headers: authorization,x-request-id

Our API answered with no matching CORS headers for preflight. Browser blocked the real request before it ever reached the handler.

Classic.

After: a production-safe Gin config

We replaced the wildcard setup with explicit origins, explicit headers, and exposed only what the frontend needed.

package main

import (
	"net/http"
	"time"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.Use(cors.New(cors.Config{
		AllowOrigins: []string{
			"http://localhost:3000",
			"https://app.example.com",
			"https://admin.example.com",
		},
		AllowMethods: []string{
			http.MethodGet,
			http.MethodPost,
			http.MethodPut,
			http.MethodPatch,
			http.MethodDelete,
			http.MethodOptions,
		},
		AllowHeaders: []string{
			"Authorization",
			"Content-Type",
			"X-Request-ID",
		},
		ExposeHeaders: []string{
			"X-Request-ID",
			"Link",
			"ETag",
		},
		AllowCredentials: true,
		MaxAge: 12 * time.Hour,
	}))

	r.OPTIONS("/*path", func(c *gin.Context) {
		c.Status(http.StatusNoContent)
	})

	r.POST("/login", loginHandler)
	r.GET("/me", meHandler)

	r.Run(":8080")
}

Why this fixed it

  • AllowOrigins matches the real frontend origins
  • AllowHeaders includes Authorization and X-Request-ID, so preflight passes
  • ExposeHeaders lets browser JavaScript read response headers like Link and ETag
  • AllowCredentials: true works because we are not using *
  • explicit OPTIONS handling avoids weird middleware ordering issues

That last point matters more than people think. In mixed middleware stacks, I’ve seen auth middleware intercept OPTIONS and return 401, which breaks preflight even though the route itself is fine.

If your auth middleware touches preflight, move CORS earlier or skip auth for OPTIONS.

Before: the Echo version that looked correct, but wasn’t

Echo had a similar issue. The original config:

package main

import (
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()

	e.Use(middleware.CORS())

	e.GET("/me", meHandler)
	e.Start(":8080")
}

middleware.CORS() with defaults is better than nothing, but defaults rarely match a real app’s auth and header needs.

Then came the second mistake: trying to bolt on credentials while still allowing everything.

e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
	AllowOrigins:     []string{"*"},
	AllowCredentials: true,
}))

That combination is a bad idea and browsers won’t accept it in the way people expect. If you need credentials, stop using *.

After: Echo config that survives staging and production

package main

import (
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()

	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: []string{
			"http://localhost:3000",
			"https://app.example.com",
			"https://admin.example.com",
		},
		AllowMethods: []string{
			http.MethodGet,
			http.MethodPost,
			http.MethodPut,
			http.MethodPatch,
			http.MethodDelete,
			http.MethodOptions,
		},
		AllowHeaders: []string{
			echo.HeaderOrigin,
			echo.HeaderContentType,
			echo.HeaderAccept,
			echo.HeaderAuthorization,
			"X-Request-ID",
		},
		ExposeHeaders: []string{
			"X-Request-ID",
			"Link",
			"ETag",
		},
		AllowCredentials: true,
		MaxAge: int((12 * time.Hour).Seconds()),
	}))

	e.OPTIONS("/*", func(c echo.Context) error {
		return c.NoContent(http.StatusNoContent)
	})

	e.GET("/me", meHandler)
	e.Start(":8080")
}

Same pattern, same fix.

The sneaky bug: headers the frontend can’t read

One issue took longer to spot. Requests were succeeding, but frontend code still behaved like pagination was broken.

The API returned:

Link: <https://api.example.com/items?page=2>; rel="next"
ETag: "abc123"
X-Request-ID: req_42

Browser devtools showed those headers in the network tab, so the frontend team assumed they could read them.

Not necessarily.

Without Access-Control-Expose-Headers, browser JavaScript can only access a limited safelisted set. That’s why GitHub explicitly exposes a long list of headers, including ETag and Link.

Once we added:

ExposeHeaders: []string{"X-Request-ID", "Link", "ETag"},

the frontend could finally do:

const res = await fetch("https://api.example.com/items");
console.log(res.headers.get("Link"));
console.log(res.headers.get("ETag"));
console.log(res.headers.get("X-Request-ID"));

Before that, those calls returned null.

How we debugged it quickly

When I’m debugging CORS, I want to see the raw headers first, not guess from browser error messages. Browser consoles are noisy and sometimes misleading.

A header inspection tool like HeaderTest is handy for checking what your API actually returns for OPTIONS and normal requests, especially across environments where proxies and CDNs might rewrite things.

I also like reproducing preflight with curl:

curl -i -X OPTIONS https://api.example.com/me \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: authorization,x-request-id"

You want to see something like:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization,Content-Type,X-Request-ID
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 43200
Vary: Origin

If that response is missing or wrong, the browser is going to block the actual request.

What changed operationally

The code fix was small. The real improvement was policy.

We ended up with a few rules:

  • public endpoints can use broad CORS, authenticated endpoints cannot
  • credentials means no wildcard origin
  • every custom request header must be explicitly allowed
  • every response header used by frontend code must be explicitly exposed
  • preflight must bypass auth and return fast
  • origin lists live in config, not hardcoded forever in source

That last one matters once you add preview deployments, regional frontends, or separate admin apps.

A practical baseline

If you’re building a Go API with Gin or Echo for a browser frontend, this is my default position:

  • start explicit, not permissive
  • list exact origins
  • allow only the methods and headers you use
  • expose only the headers your frontend reads
  • support OPTIONS cleanly
  • review CORS together with the rest of your response header strategy

If you’re tightening broader web response hardening beyond CORS, stuff like CSP belongs in the same conversation, and csp-guide.com is a good reference for that side of the work.

CORS is one of those things that feels annoying until you treat it as a real interface contract. Once you do, Gin and Echo are both perfectly fine. The bugs usually aren’t in Go. They’re in the assumptions.