← Back to list

CORS in Go: From “Blocked by Policy” to a Secure Implementation

CORS is not a server feature — it’s a browser security mechanism.

Feildrix · 2026-05-29 05:01 · 1 claps · 5.2 min read paywalled
#golang #programming #backend-development #software-development #cors
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

CORS in Go: From “Blocked by Policy” to a Secure Implementation

CORS is not a server feature — it’s a browser security mechanism.

You just deployed your Go backend to production. Your React frontend calls the API from a different domain. Then you see it in the browser console:

*Access to fetch at 'https://api.yourapp.com/users' from origin 'https://app.yourapp.com' has been blocked by CORS policy.*

The first instinct for most developers: slap Access-Control-Allow-Origin: * on it and move on. But that's not a solution — it's a ticking time bomb.

CORS (Cross-Origin Resource Sharing) is one of the most misunderstood concepts in web development, and a misconfigured implementation can open serious security holes. In this article, we’ll break down how CORS actually works, why “just make it work” implementations are dangerous, and how to implement it correctly in Go.

Understanding CORS: The Browser Is Protecting You (Not Fighting You)

The first thing to get straight: CORS is not a server feature — it’s a browser security mechanism.

When you run curl against your API, there's no CORS error. When Postman calls it, same thing. CORS errors only happen when a browser detects that JavaScript on a web page is trying to access a resource from a different origin — a combination of scheme, domain, and port.

https://app.yourapp.com  →  different origin  →  https://api.yourapp.com
http://localhost:3000    →  different origin  →  http://localhost:8080

Browsers enforce the Same-Origin Policy (SOP) by default: JavaScript can only read responses from the same origin. CORS is the HTTP-header mechanism that lets a server explicitly permit access from other origins.

Simple Requests vs. Preflight

Simple requests (GET, POST with standard Content-Type) — the browser sends the request directly, then checks the response headers.

Non-simple requests (PUT, DELETE, or requests with custom headers like Authorization) — the browser first sends a preflight request using the OPTIONS method, asking for permission before the real request is sent:

Browser → OPTIONS /api/users   (preflight: "am I allowed to PUT here?")
Server  → 204 No Content + CORS headers
Browser → PUT /api/users       (actual request)

This is critical to understand: if your server doesn’t handle OPTIONS correctly, every non-simple request will fail — including the extremely common API call with an Authorization header.

Implementing CORS in Go: Three Approaches

1. Manual Middleware with net/http

To understand the fundamentals, let’s write a CORS middleware from scratch:

package main

import (
    "log"
    "net/http"
)

func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
    allowedSet := make(map[string]bool)
    for _, o := range allowedOrigins {
        allowedSet[o] = true
    }
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")
            // Only set headers if the origin is whitelisted
            if allowedSet[origin] {
                w.Header().Set("Access-Control-Allow-Origin", origin)
                w.Header().Set("Vary", "Origin") // critical for correct cache behavior
                w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
                w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
            }
            // Handle preflight request
            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"users": []}`))
    })
    allowedOrigins := []string{
        "https://app.yourapp.com",
        "https://admin.yourapp.com",
    }
    handler := corsMiddleware(allowedOrigins)(mux)
    log.Fatal(http.ListenAndServe(":8080", handler))
}

This approach is transparent and great for learning, but it’s error-prone in production. Battle-tested libraries handle the edge cases for you.

2. Using rs/cors (The Popular Choice)

[rs/cors](https://github.com/rs/cors) is the most widely used CORS library in the Go ecosystem:

go get github.com/rs/cors
package main

import (
    "log"
    "net/http"
    "github.com/rs/cors"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/products", getProducts)
    mux.HandleFunc("POST /api/products", createProduct)
    mux.HandleFunc("PUT /api/products/{id}", updateProduct)
    c := cors.New(cors.Options{
        AllowedOrigins:   []string{"https://app.yourapp.com"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE"},
        AllowedHeaders:   []string{"Content-Type", "Authorization"},
        AllowCredentials: true,
        MaxAge:           300, // cache preflight for 5 minutes
    })
    handler := c.Handler(mux)
    log.Fatal(http.ListenAndServe(":8080", handler))
}

func getProducts(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"products": []}`))
}

func createProduct(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusCreated)
    w.Write([]byte(`{"message": "created"}`))
}

func updateProduct(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    w.Write([]byte(`{"id": "` + id + `", "updated": true}`))
}

⚠️ Security note: Older versions of [rs/cors](https://github.com/rs/cors) are vulnerable to a DoS attack (CVE-2025-47908) — an attacker can send preflight requests with many commas in the Access-Control-Request-Headers header, forcing the server to allocate excessive memory. Always use the latest version.

3. Using jub0bs/cors (The Modern Choice, 2025–2026)

This library is designed to be both safer and harder to misconfigure. It requires Go 1.25+:

go get github.com/jub0bs/cors
package main

import (
    "log"
    "net/http"
    "github.com/jub0bs/cors"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users", getUsers)
    mux.HandleFunc("POST /api/users", createUser)
    corsMw, err := cors.NewMiddleware(cors.Config{
        Origins:        []string{"https://app.yourapp.com"},
        Methods:        []string{http.MethodGet, http.MethodPost, http.MethodPut},
        RequestHeaders: []string{"Authorization", "Content-Type"},
    })
    if err != nil {
        // Invalid config is caught at startup, not at runtime
        log.Fatal("CORS config error:", err)
    }
    corsMw.SetDebug(true) // enable during development
    handler := corsMw.Handler(mux)
    log.Fatal(http.ListenAndServe(":8080", handler))
}

func getUsers(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"users": []}`))
}

func createUser(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusCreated)
    w.Write([]byte(`{"message": "user created"}`))
}

The key advantage of [jub0bs/cors](https://github.com/jub0bs/cors): if your configuration is invalid, NewMiddleware returns an error at startup — you know exactly what's wrong before the server ever handles a single client request. No silent failures.

Three Traps You Must Avoid

❌ Trap 1: Wildcard + Credentials

// NEVER do this in production
cors.New(cors.Options{
    AllowedOrigins:   []string{"*"},
    AllowCredentials: true, // browsers will REJECT this combination
})

Browsers explicitly forbid combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. This isn't a library bug — it's the CORS Fetch spec. Use explicit origins whenever you need credentials.

❌ Trap 2: Auth Middleware Before CORS

// WRONG: preflight carries no credentials
// → auth middleware rejects OPTIONS → CORS never responds
handler := authMiddleware(corsMiddleware(mux))

// CORRECT: CORS middleware must be the outermost layer
handler := corsMiddleware(authMiddleware(mux))

Preflight requests (OPTIONS) don't carry an Authorization header — that's by design. If your auth middleware rejects every request without a token, it will block the preflight before CORS can respond. Result: every API call with a custom header silently fails.

❌ Trap 3: Forgetting OPTIONS in Go 1.22+ Enhanced Routing

With Go’s method-specific routing patterns (GET /api/users), OPTIONS /api/users is not automatically handled. Your CORS middleware must wrap the entire ServeMux, not individual routes:

// Correct: CORS middleware wraps the entire mux
handler := corsMw.Handler(mux)
log.Fatal(http.ListenAndServe(":8080", handler))

If you apply CORS only to specific routes using method-prefixed patterns, preflight requests to those routes will fall through unhandled.

Pre-Deploy Checklist

Before shipping your Go backend to production, verify every item on this list:

  • Explicit origin whitelist — no * in production; register every legitimate domain
  • Update your CORS library — older [rs/cors](https://github.com/rs/cors) has a DoS CVE; update or migrate to [jub0bs/cors](https://github.com/jub0bs/cors)
  • CORS middleware at the outermost layer — before auth, rate limiting, or any other middleware
  • Set MaxAge — cache preflight responses in the browser (300–600 seconds is reasonable) to avoid repeated OPTIONS round-trips
  • Limit ExposedHeaders — only expose headers that JavaScript on the client actually needs to read
  • Disable debug mode in production — debug mode can leak internal configuration details

Conclusion

CORS is not a bug to be worked around — it’s a browser security feature protecting your users from cross-origin attacks. Understanding the flow — Same-Origin Policy → Preflight → Header Validation — is the foundation for implementing it correctly and confidently.

In Go, you have solid options: [rs/cors](https://github.com/rs/cors) for broad compatibility with the existing ecosystem, [jub0bs/cors](https://github.com/jub0bs/cors) for stronger security and strict configuration validation on Go 1.25+, or manual middleware when your requirements are highly specific.

The takeaway: stop treating CORS as an obstacle to bypass. Treat it as a contract between your server and your users’ browsers.

Start with [jub0bs/cors](https://github.com/jub0bs/cors), read the error it gives you, and register your origins explicitly — that single discipline already puts you ahead of the vast majority of CORS implementations running in production today.

Hit a weird CORS edge case in Go? Drop it in the comments and share your experience.


메타데이터
post_id
fea99bd9deaa
slug
cors-in-go-from-blocked-by-policy-to-a-secure-implementation-fea99bd9deaa
url
https://medium.com/@feildrixliemdra/cors-in-go-from-blocked-by-policy-to-a-secure-implementation-fea99bd9deaa
canonical_url
https://medium.com/@feildrixliemdra/cors-in-go-from-blocked-by-policy-to-a-secure-implementation-fea99bd9deaa
author_url
https://medium.com/@feildrixliemdra
status
ok
fetched_at
2026-06-15 20:49:13