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.
None of these replace authentication or input validation, but they shrink blast radius when something else is misconfigured.
Summary
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.
Recipe
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:
- Public HTTP APIs exposed to the internet or partner integrations.
- Browser-facing Go backends serving SPAs or form posts.
- Services behind a load balancer where the app still owns CORS and security headers.
- Endpoints vulnerable to credential stuffing, scraping, or expensive compute per request.
Working Example
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:
- Explicit CORS allowlist with
Vary: Originand OPTIONS preflight handling. - In-memory token bucket rate limiter keyed by client IP.
- Security headers including HSTS and CSP on every response path.
- Middleware composition order: rate limit inside CORS inside security headers (adjust per deployment).
Deep Dive
How It Works
- Rate limiters track consumption per key (IP, API key, user ID).
Token bucket allows bursts while maintaining average rate.
Distributed deployments need Redis or gateway-level limits - in-memory maps are per-process only.
- CORS applies only when browsers send an
Originheader.
Allowing * with credentials is invalid.
Reflecting arbitrary Origin values enables credentialed cross-site data theft.
- Security headers are advisory except HSTS (after first HTTPS visit).
CSP reduces XSS impact by blocking inline script unless nonced.
X-Content-Type-Options: nosniff stops MIME sniffing attacks.
Header Reference
| 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) |
Rate Limit Placement
| 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 |
Go Notes
// 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
}Gotchas
- CORS wildcard with credentials -
Access-Control-Allow-Origin: *cannot be used withAllow-Credentials: true. Use explicit origins. - Reflecting Origin header - Dynamic
Allow-Origin: r.Header.Get("Origin")without allowlist equals open CORS. Always map against a set. - Rate limit by raw RemoteAddr behind LB - All traffic appears as one IP. Parse
X-Forwarded-Foronly when the proxy strips untrusted client values. - Skipping headers on errors -
http.Errorbypasses middleware if you call it before the wrapper. Ensure headers middleware wraps the entire handler tree. - HSTS on HTTP-only dev - HSTS on
http://localhostcan confuse local testing. Enable HSTS only on production TLS listeners. - CSP breaking SPA assets -
default-src 'self'blocks CDN scripts. Tune CSP per deployment or use nonces for inline scripts. - OPTIONS without rate limit - Attackers can flood preflight. Apply limits to OPTIONS or handle CORS at the gateway.
Alternatives
| 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) |
FAQs
Does CORS protect my API from curl or Postman?
No.
CORS is enforced by browsers only.
Always authenticate and authorize regardless of CORS settings.
Where should rate limiting sit in the middleware chain?
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.
What HTTP status should rate limits return?
429 Too Many Requests with Retry-After when you can estimate reset time.
Log and metric the key being throttled for abuse analysis.
Should internal APIs set CORS?
Usually no - browsers should not call internal hostnames.
If an internal admin SPA exists, allowlist its origin explicitly.
How do I rate limit gRPC?
Use interceptors with a shared limiter keyed by metadata (API key or peer cert subject).
Or enforce at the service mesh / gateway.
Is HSTS necessary behind TLS-terminating ingress?
Often set at the ingress layer.
If TLS reaches the Go process directly, set HSTS there too for direct client connections.
What CSP works for a JSON-only API?
default-src 'none'; frame-ancestors 'none' signals browsers not to treat responses as active content.
Pair with correct Content-Type: application/json.
How do I test CORS in Go?
httptest with Origin header and OPTIONS method.
Assert Access-Control-Allow-Origin only for allowlisted values.
Can one middleware set all security headers?
Yes - a single securityHeaders wrapper keeps policy consistent.
Document header values in your service template README.
What about rate limiting login endpoints?
Use stricter limits and exponential backoff on /login to slow credential stuffing.
Consider CAPTCHA or IdP rate limits for user-facing auth.
Should I use X-XSS-Protection?
Deprecated in modern browsers.
Invest in CSP and correct output encoding instead.
How do security headers interact with gRPC-Web?
gRPC-Web may traverse browsers; CORS and CSP still apply to the HTTP façade.
Configure allowlists for gRPC-Web gateway origins.
Related
- Go Security Basics - server timeouts complement rate limits
- Secure Cookies & CSRF for Web Apps - browser session hardening
- Authentication & Authorization Patterns - per-user rate limits after auth
- Security for Go Services: Defaults and Gaps - edge layer in defense in depth
- Security Best Practices Summary - header and CORS checklist items
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).