You built an API. You tested it with curl and every endpoint answered perfectly. Then you pointed a front end at it and got this:
Access to fetch at 'http://localhost:8080/api/login' from origin
'http://localhost:5173' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check.
Nothing is broken. Your API is fine. curl was never the test.
I hit this building a Vue front end for a Go helpdesk API, so here is what is actually happening and the middleware that fixes it — no library.
curl is not a browser
curl sends your request and hands you the reply. It does not care where the request came from, because curl has no concept of a page that made it.
A browser does. It knows the JavaScript calling fetch came from http://localhost:5173, and it knows you're calling http://localhost:8080. Different port means different origin, and by default a page may only call its own origin.
This rule exists for a good reason. Without it, any site you visited could quietly call yourbank.com/api/transfer using the cookies already in your browser. The block is protecting the user, not inconveniencing you.
So CORS — Cross-Origin Resource Sharing — is the mechanism by which a server says "it's fine, I know about that origin, let them through."
Two things follow from that, and they're the two things people get wrong.
The permission comes from the server, not the client. You cannot fix CORS in your front-end code. Every "fix" you find that involves changing fetch options is either wrong or a proxy in disguise.
The browser enforces it, not the server. The request often reaches your API and your API often answers. The browser then throws the answer away before your JavaScript sees it. That's why your server logs show a perfectly normal 200 while the console shows a failure. Deeply confusing the first time.
The preflight
For a simple GET, the browser sends the request and checks the reply for permission.
For anything else — POST with JSON, PATCH, DELETE, or any request with an Authorization header — it asks first. This is the preflight: an OPTIONS request to the same URL, saying "I'm from this origin, I want to use this method, I want to send these headers — may I?"
Your server must answer that OPTIONS request with permission headers. If it doesn't, the real request is never sent at all.
This is why people are baffled that their GET works and their POST doesn't.
The middleware
Here's the whole thing in Go, standard library only:
func withCORS(allowed []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, allowed) {
w.Header().Set("Access-Control-Allow-Origin", origin)
// the reply changes with the Origin, so caches must key on it
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods",
"GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers",
"Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "86400")
}
// a preflight is answered here and never reaches a handler
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Wrap your router with it:
func (a *App) routes() http.Handler {
mux := http.NewServeMux()
// ... all your routes ...
return withCORS(a.AllowOrigins, logRequests(mux))
}
Four details in there are worth spelling out.
Access-Control-Allow-Headers must list Authorization. This is the single most common cause of "I added CORS and it still doesn't work." If your API uses a bearer token and this header doesn't mention Authorization, the browser refuses to send the token and every authenticated call fails.
Echo the origin, don't hardcode one. Reflecting the caller's origin — after checking it's allowed — lets one deployment serve several front ends.
Vary: Origin is not optional. Your response now differs depending on who asked. Without this header a cache can store the reply for one origin and serve it to another, producing failures that only appear in production and only sometimes.
Max-Age stops the preflight tax. Without it the browser preflights every POST. With it, it asks once a day.
Never use *
Every tutorial shows Access-Control-Allow-Origin: *. It works, and it means any website on the internet may call this API.
For a public read-only API that's fine. For anything with authentication it is not — and the browser knows it, which is why * is simply ignored when credentials are involved.
Take the allowed origins from configuration instead:
origins := strings.Split(getenv("ALLOW_ORIGINS", "http://localhost:5173"), ",")
Localhost in development, your real domain in production, same code.
Test it without a browser
You don't have to guess. Send the preflight yourself:
curl -i -X OPTIONS localhost:8080/api/login \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST'
You want:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin
That is exactly what the browser does before every POST. If this works, the browser will too.
And because it's just headers, you can test it automatically:
func TestCORS(t *testing.T) {
srv := newTestServer(t)
const allowed = "http://localhost:5173"
req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/login", nil)
req.Header.Set("Origin", allowed)
res, _ := http.DefaultClient.Do(req)
if res.StatusCode != http.StatusNoContent {
t.Errorf("preflight status: got %d, want 204", res.StatusCode)
}
if got := res.Header.Get("Access-Control-Allow-Origin"); got != allowed {
t.Errorf("allow-origin: got %q, want %q", got, allowed)
}
// an origin we did not allow must get nothing
req2, _ := http.NewRequest("OPTIONS", srv.URL+"/api/login", nil)
req2.Header.Set("Origin", "http://evil.example")
res2, _ := http.DefaultClient.Do(req2)
if got := res2.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("unknown origin was allowed: %q", got)
}
}
That second half matters more than the first. It's easy to write CORS middleware that allows everything by accident, and a test that only checks the happy path will happily pass.
The one that catches everyone in production
Your front end works locally. You deploy it. It breaks.
ALLOW_ORIGINS is still set to localhost. Set it to your real domain.
And while you're there: if your front end uses client-side routing, tell your static host to serve index.html for unknown paths, or refreshing /tickets/1 gives a 404. Different problem, same deployment day, equally annoying.
Full code
All of this is from a real project — a helpdesk API in Go with JWT auth and a Vue 3 front end, both free:
https://github.com/Abbysifuni/helpdesk-api
I also walked through building it end to end on video. The API series is here:
And the Vue front end, including this CORS work:
If CORS has bitten you somewhere I haven't covered, tell me in the comments — the failure modes are weirdly varied and I'd like to collect them.
Top comments (0)