Rate Limiting, CORS & Security Headers
Edge middleware in Go services reduces abuse, defines browser cross-origin policy, and sets response headers that harden clients against common web attacks.
Search across all documentation pages
Edge middleware in Go services reduces abuse, defines browser cross-origin policy, and sets response headers that harden clients against common web attacks.
None of these replace authentication or input validation, but they shrink blast radius when something else is misconfigured.
Rate limiting caps how many requests a client can make in a time window, protecting CPU, database connections, and downstream quotas.
CORS tells browsers which cross-origin JavaScript may read responses - it does not block non-browser clients.
Security headers instruct browsers to enforce HTTPS, restrict framing, and limit script and resource loading.
Implement all three as http.Handler wrappers you apply consistently, including on error paths.
Quick-reference recipe card - copy-paste ready.
func securityHeaders(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", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'")
next.ServeHTTP(w, r)
})
}When to reach for this:
package main
import (
"net/http"
"strings"
"sync"
"time"
)
var allowedOrigins = map[string]bool{
"https://app.example.com": true,
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
type ipLimiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket
}
type tokenBucket struct {
tokens float64
last time.Time
}
func (l *ipLimiter) allow(key string, rate float64, burst float64) bool {
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.buckets[key]
now := time.Now()
if !ok {
b = &tokenBucket{tokens: burst, last: now}
l.buckets[key] = b
}
elapsed := now.Sub(b.last).Seconds()
b.tokens = min(burst, b.tokens+elapsed*rate)
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func rateLimit(l *ipLimiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := strings.Split(r.RemoteAddr, ":")[0]
if !l.allow(ip, 5, 10) {
w.Header().Set("Retry-After", "1")
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func main() {
limiter := &ipLimiter{buckets: make(map[string]*tokenBucket)}
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"ok":true}`))
})
handler := securityHeaders(cors(rateLimit(limiter, mux)))
http.ListenAndServe(":8080", handler)
}
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}What this demonstrates:
Vary: Origin and OPTIONS preflight handling.Token bucket allows bursts while maintaining average rate.
Distributed deployments need Redis or gateway-level limits - in-memory maps are per-process only.
Origin header.Allowing * with credentials is invalid.
Reflecting arbitrary Origin values enables credentialed cross-site data theft.
CSP reduces XSS impact by blocking inline script unless nonced.
X-Content-Type-Options: nosniff stops MIME sniffing attacks.
| Header | Purpose |
|---|---|
Strict-Transport-Security | Force HTTPS for future visits |
Content-Security-Policy | Restrict script, style, and frame sources |
X-Frame-Options / frame-ancestors | Clickjacking protection |
X-Content-Type-Options | Prevent MIME confusion |
Referrer-Policy | Limit leakage in Referer header |
Permissions-Policy | Disable browser features (camera, geolocation) |
| Tier | Location | Notes |
|---|---|---|
| Global | API gateway / CDN | Best for DDoS volume |
| Per service | Go middleware | Fine-grained per route cost |
| Per user | After auth middleware | Fair quotas for paid tiers |
| Expensive endpoints | Handler wrapper | Lower limits on /export vs /health |
// Behind reverse proxy: trust X-Forwarded-For only from known LB IPs
func clientIP(r *http.Request, trusted bool) string {
if trusted {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}Access-Control-Allow-Origin: * cannot be used with Allow-Credentials: true. Use explicit origins.Allow-Origin: r.Header.Get("Origin") without allowlist equals open CORS. Always map against a set.X-Forwarded-For only when the proxy strips untrusted client values.http.Error bypasses middleware if you call it before the wrapper. Ensure headers middleware wraps the entire handler tree.http://localhost can confuse local testing. Enable HSTS only on production TLS listeners.default-src 'self' blocks CDN scripts. Tune CSP per deployment or use nonces for inline scripts.| Alternative | Use When | Don't Use When |
|---|---|---|
| API gateway rate limits | High volume, uniform policy | You need per-tenant logic only the app knows |
golang.org/x/time/rate | Standard library-friendly limiter | You already use a framework middleware |
| chi/gin/echo CORS middleware | Router-integrated CORS | You want zero extra dependencies |
| CDN WAF | Edge attack signatures | Substitute for app auth (insufficient alone) |
No.
CORS is enforced by browsers only.
Always authenticate and authorize regardless of CORS settings.
After request ID and logging, often before or after auth depending on whether limits are per-IP (before) or per-user (after).
Never after handlers - it must short-circuit first.
429 Too Many Requests with Retry-After when you can estimate reset time.
Log and metric the key being throttled for abuse analysis.
Usually no - browsers should not call internal hostnames.
If an internal admin SPA exists, allowlist its origin explicitly.
Use interceptors with a shared limiter keyed by metadata (API key or peer cert subject).
Or enforce at the service mesh / gateway.
Often set at the ingress layer.
If TLS reaches the Go process directly, set HSTS there too for direct client connections.
default-src 'none'; frame-ancestors 'none' signals browsers not to treat responses as active content.
Pair with correct Content-Type: application/json.
httptest with Origin header and OPTIONS method.
Assert Access-Control-Allow-Origin only for allowlisted values.
Yes - a single securityHeaders wrapper keeps policy consistent.
Document header values in your service template README.
Use stricter limits and exponential backoff on /login to slow credential stuffing.
Consider CAPTCHA or IdP rate limits for user-facing auth.
Deprecated in modern browsers.
Invest in CSP and correct output encoding instead.
gRPC-Web may traverse browsers; CORS and CSP still apply to the HTTP façade.
Configure allowlists for gRPC-Web gateway origins.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify at build).
Reviewed by Chris St. John·Last updated Jul 16, 2026