Dev Skills

Rate limiting, CORS, and security headers

Stop somebody brute-forcing your login, and set the headers browsers rely on.

Topic 83 of 98 · 5 min to read · 50 min to build
After this topic you can
  • 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.Client with 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.

internal/middleware/ratelimit.go
// 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)
})
}
}
The map grows forever as written. Add an eviction sweep, or use an LRU, before this goes anywhere real.

Limit the auth routes hardest

main.go
authLimit := middleware.RateLimit(0.2, 5) // ~12/minute, burst 5
apiLimit := middleware.RateLimit(20, 40) // generous for normal use
mux.Handle("POST /auth/login", authLimit(http.HandlerFunc(h.login)))
mux.Handle("POST /auth/register", authLimit(http.HandlerFunc(h.register)))
Login, register, and password reset are where credential stuffing lands. They get their own, much tighter limit.
Watch out: Behind a proxy or load balancer, 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.

internal/middleware/cors.go
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)
})
}
}
Watch out: Never 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

internal/middleware/secure.go
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)
})
}
For an API that serves uploads, nosniff matters most. It stops a browser deciding that an uploaded file is HTML and running its scripts.

The HTTP client, while you are here

client.go
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
The short version
  • 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. Set Vary: 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.
Remember: Rate limiting turns "an attacker gets unlimited guesses" into "an attacker gets twelve a minute". For a few dozen lines, few defences buy more.
Build this before moving on
Add the rate limiter, CORS, and security headers. Then hammer your login with a curl loop and confirm you start getting 429s with a Retry-After.
Then try these in your own editor
  1. 1Loop curl against your login endpoint until you get 429, and check the Retry-After header.
  2. 2Call your API from a browser page on a different origin, with and without the origin allowlisted.
  3. 3Confirm X-Content-Type-Options: nosniff is present on an attachment download.
  4. 4Make an outbound call with http.DefaultClient against a server that never responds, and watch it hang forever.
Check yourselfTake the Production test