Your API works in curl but not in the browser - that's CORS
DEV Community

Your API works in curl but not in the browser - that's CORS

Your API Works in curl but Not in the Browser - That's CORS

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 error:

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. You built a Vue front end for a Go helpdesk API, so here is what is actually happening and the middleware that fixes it - no library.


What Is CORS?

CORS stands for Cross-Origin Resource Sharing. It is the mechanism by which a server signals that it is safe for a browser to make requests to other origins. When a browser makes a cross-origin request, it performs a security check called the preflight. If the server responds with appropriate headers, the actual request proceeds. Otherwise, the browser silently discards the response.

Two critical misconceptions emerge from this:

  1. Permission comes from the server, not the client. You cannot fix CORS in your front-end code. Every "fix" involving changing fetch options is either wrong or a proxy in disguise. The browser enforces CORS; the server decides whether to allow.

  2. The request often reaches your API and your API often answers. The browser then throws the answer away before your JavaScript sees it. That is why your server logs show a perfectly normal 200 while the console shows a failure.


The Preflight Mechanism

For a simple GET, the browser sends the request directly and checks the reply for permission. For anything else - POST with JSON, PATCH, DELETE, or any request with an Authorization header - the browser first asks. 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 does not, the real request is never sent at all. This explains why people are baffled that their GET works and their POST does not.


The Solution: CORS Middleware in Go

Here is the complete middleware implemented with the 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
        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 Critical Details

There are four specific aspects of the middleware that deserve special attention:

  1. 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 does not mention Authorization, the browser refuses to send the token and every authenticated call fails.

  2. Echo the origin, don't hardcode one. After verifying that the origin is allowed, reflect the caller's origin back in the response. This lets a single deployment serve multiple front ends without duplicating configuration.

  3. Vary: Origin is not optional. The response must include the Vary: Origin header. Without it, a cache can store the reply for one origin and incorrectly serve it to another, producing failures that only appear in production and only sometimes.

  4. Max-Age stops the preflight tax. Without it, the browser preflights every POST. With it, the browser asks once per day (86400 seconds). Never use * for Access-Control-Allow-Origin in production - it allows any website on the internet to call your API, which is insecure for authenticated endpoints.

Take the allowed origins from configuration instead:

origins := strings.Split(getenv("ALLOW_ORIGINS", "http://localhost:5173"), ",")

In development, ALLOW_ORIGINS might be "http://localhost:5173". In production, replace it with your real domain.


Test It Without a Browser

You do not have to guess. Send the preflight yourself using curl:

curl -i -X OPTIONS localhost:8080/api/login \
     -H 'Origin: http://localhost:5173' \
     -H 'Access-Control-Request-Method: POST'

Expected output:

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

If this works, the browser will too. Because it is just headers, you can also test it automatically with a unit test:

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)
    }
}

The second part of this test is especially important: it ensures that unauthorized origins are rejected rather than being permitted accidentally.


Common Pitfalls and Best Practices

  • Avoid * for Access-Control-Allow-Origin in production. While it works for public read-only APIs, it exposes your API to any website on the internet. When credentials are involved, the browser ignores * entirely.

  • Always echo the origin after validating it. Hardcoding a single origin limits flexibility across multiple front ends sharing the same backend.

  • Include Vary: Origin in the response header. Without it, caching behavior becomes unpredictable and can cause subtle bugs in production.

  • Use Access-Control-Allow-Methods with the full set of methods your API supports (GET, POST, PATCH, DELETE, OPTIONS). Omitting methods causes legitimate requests to fail even though the server would accept them.

  • Set Access-Control-Max-Age to 86400 (one day) to reduce the frequency of preflight checks. Without this, the browser will perform a preflight for every single POST request, adding unnecessary overhead.

By understanding and implementing these patterns, you can confidently move from successful local curl tests to reliable browser-based interactions without ever needing a third-party CORS library.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.