Rate limiting, CORS, and security headers
Stop somebody brute-forcing your login, and set the headers browsers rely on.
- Build a per-IP rate limiter and apply the strictest limit to auth routes.
- Explain what CORS protects and configure an allowed-origin list.
- Set the standard security headers and an
http.Clientwith a timeout.
Three defences, and what each one stops
Rate limiting caps how often one client can call you. Without it, a script can try thousands of passwords a minute against your login.
CORS (Cross-Origin Resource Sharing) is how your API tells a browser which other websites may call it from their pages and read the response. The browser enforces it, not your server. curl, a script or another server ignores it completely, so it is not a lock on your API.
Security headers are short instructions to the browser: do not guess this file's type, do not let this page be framed, always use HTTPS. A few lines of code that close whole classes of attack.
Rate limiting
golang.org/x/time/rate gives you a token-bucket limiter. Each client has a bucket that refills at a steady rate, and the bucket size is the burst: how many requests can arrive at once before the limit applies.
// ponytail: one map + mutex per process. Fine for a single instance.// Behind more than one, move to Redis or you get N times the limit.type limiter struct { mu sync.Mutex clients map[string]*rate.Limiter rps rate.Limit burst int}func (l *limiter) get(key string) *rate.Limiter { l.mu.Lock() defer l.mu.Unlock() lim, ok := l.clients[key] if !ok { lim = rate.NewLimiter(l.rps, l.burst) l.clients[key] = lim } return lim}func RateLimit(rps float64, burst int) Middleware { l := &limiter{clients: map[string]*rate.Limiter{}, rps: rate.Limit(rps), burst: burst} return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip, _, _ := net.SplitHostPort(r.RemoteAddr) if !l.get(ip).Allow() { w.Header().Set("Retry-After", "60") httpx.Error(w, 429, "too many requests") return } next.ServeHTTP(w, r) }) }}Limit the auth routes hardest
authLimit := middleware.RateLimit(0.2, 5) // ~12/minute, burst 5apiLimit := middleware.RateLimit(20, 40) // generous for normal usemux.Handle("POST /auth/login", authLimit(http.HandlerFunc(h.login)))mux.Handle("POST /auth/register", authLimit(http.HandlerFunc(h.register)))r.RemoteAddr is the proxy’s IP, so everyone shares one bucket. Use the real client IP from a header your proxy sets, such as X-Forwarded-For. Only trust that header when your own proxy sets it, or anyone can send a fake one and get a fresh bucket.CORS
A browser refuses to let a page on one origin call your API on another, unless your API says it is allowed. CORS is that permission.
func CORS(allowed []string) Middleware { ok := make(map[string]bool, len(allowed)) for _, o := range allowed { ok[o] = true } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if ok[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE") w.Header().Set("Vary", "Origin") // or a cache serves the wrong header } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) // the preflight return } next.ServeHTTP(w, r) }) }}Access-Control-Allow-Origin: * on an authenticated API. It is also invalid to combine with Allow-Credentials: true. Keep an explicit allowlist from config.Security headers
func SecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") next.ServeHTTP(w, r) })}The HTTP client, while you are here
var client = &http.Client{ Timeout: 10 * time.Second, // http.DefaultClient has NO timeout. Ever. Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 100, // the default is 2 — a hidden bottleneck IdleConnTimeout: 90 * time.Second, },}resp, err := client.Do(req)if err != nil { return err}defer resp.Body.Close()defer io.Copy(io.Discard, resp.Body) // drain it, or the connection is not reused- Token-bucket rate limiting per IP, and a much tighter limit on the auth routes.
- CORS is an explicit allowlist, never
*on an authenticated API. SetVary: Origin. nosniff,DENY, and HSTS each close a whole class of attack in one line.- Never use
http.DefaultClient. Set a timeout, reuse one client, drain every body.
- 1Loop curl against your login endpoint until you get 429, and check the
Retry-Afterheader. - 2Call your API from a browser page on a different origin, with and without the origin allowlisted.
- 3Confirm
X-Content-Type-Options: nosniffis present on an attachment download. - 4Make an outbound call with
http.DefaultClientagainst a server that never responds, and watch it hang forever.