Building a Go Middleware Chain for Authentication and Rate Limiting
Most Go HTTP services end up with the same problem: authentication logic scattered across handlers, rate limiting bolted on as an afterthought, and no consistent place to enforce common policies. A well-structured middle
Most Go HTTP services end up with the same problem: authentication logic scattered across handlers, rate limiting bolted on as an afterthought, and no consistent place to enforce common policies. A well-structured middleware chain solves this — but "middleware" in Go is simpler than it sounds, and the way you build the chain determines whether it stays maintainable.
This post walks through building a middleware chain that handles authentication and rate limiting cleanly, with real code you can adapt.
How Go Middleware Works
Go's net/http package gives you a clean extension point: any function that takes an http.Handler and returns an http.Handler is middleware.
type Middleware func(http.Handler) http.Handler
Chaining multiple middleware functions means wrapping handlers in order. The key insight: middleware closer to the outside of the chain runs first on the way in and last on the way out.
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
Usage:
mux := http.NewServeMux()
mux.Handle("/api/data", Chain(
dataHandler,
RateLimiter(100, time.Minute),
RequireAuth(tokenValidator),
RequestLogger,
))
The reversal in Chain ensures the first middleware you list in your call runs outermost — so RequestLogger wraps everything, then RequireAuth, then RateLimiter, then the actual handler.
Authentication Middleware
Here is a concrete JWT authentication middleware. It extracts the Authorization header, validates the token, and stores the claims in the request context so handlers downstream can access them without re-parsing.
type contextKey string
const claimsKey contextKey = "claims"
func RequireAuth(secret []byte) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := validateJWT(tokenStr, secret)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func ClaimsFromContext(ctx context.Context) (map[string]any, bool) {
claims, ok := ctx.Value(claimsKey).(map[string]any)
return claims, ok
}
The typed context key (contextKey string type) is important. Using a plain string as a context key creates a collision risk if other packages use the same string. The custom type makes the key package-local.
For validateJWT, you would use a library like golang-jwt/jwt. The middleware itself stays thin: validate, reject, or pass through with context enriched.
Rate Limiting Middleware
A token bucket rate limiter per IP address is the right default for most APIs. The standard library does not include one, but golang.org/x/time/rate does.
import "golang.org/x/time/rate"
type ipLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
type RateLimitMiddleware struct {
mu sync.Mutex
limiters map[string]*ipLimiter
rate rate.Limit
burst int
}
func NewRateLimiter(reqPerSec float64, burst int) *RateLimitMiddleware {
rl := &RateLimitMiddleware{
limiters: make(map[string]*ipLimiter),
rate: rate.Limit(reqPerSec),
burst: burst,
}
go rl.cleanup()
return rl
}
func (rl *RateLimitMiddleware) getLimiter(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
if il, exists := rl.limiters[ip]; exists {
il.lastSeen = time.Now()
return il.limiter
}
l := rate.NewLimiter(rl.rate, rl.burst)
rl.limiters[ip] = &ipLimiter{limiter: l, lastSeen: time.Now()}
return l
}
func (rl *RateLimitMiddleware) Middleware() Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limiter := rl.getLimiter(ip)
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
func (rl *RateLimitMiddleware) cleanup() {
for range time.Tick(5 * time.Minute) {
rl.mu.Lock()
for ip, il := range rl.limiters {
if time.Since(il.lastSeen) > 10*time.Minute {
delete(rl.limiters, ip)
}
}
rl.mu.Unlock()
}
}
Two things worth noting: the cleanup goroutine prevents unbounded memory growth if you serve many distinct IPs. And r.RemoteAddr gives you the direct connection IP — if you are behind a reverse proxy, you need X-Forwarded-For or X-Real-IP instead, but trust those headers only from your known proxy IPs.
Composing the Chain
Here is how a full server setup looks with both middleware pieces wired together:
func main() {
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
rateLimiter := NewRateLimiter(10, 30) // 10 req/s, burst of 30
mux := http.NewServeMux()
mux.Handle("/api/profile", Chain(
profileHandler(),
rateLimiter.Middleware(),
RequireAuth(jwtSecret),
requestLogger(),
))
mux.Handle("/api/public", Chain(
publicHandler(),
rateLimiter.Middleware(),
requestLogger(),
))
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
Public routes get the rate limiter but not authentication. Protected routes get both. The Chain function makes the intent explicit and the order visible at a glance — no hidden wiring.
Testing Middleware in Isolation
Each middleware is a pure function from http.Handler to http.Handler, which makes it straightforward to test without standing up a real server:
func TestRequireAuth(t *testing.T) {
secret := []byte("test-secret")
handler := RequireAuth(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
t.Fatal("no claims in context")
}
w.WriteHeader(http.StatusOK)
_ = claims
}))
// Missing token
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
}
httptest.NewRequest and httptest.NewRecorder from the standard library give you everything needed. You can test each middleware independently without integration overhead.
For a broader reference on securing Go APIs — including header hardening and TLS configuration — see the AYI NEDJIMI security checklists, which cover these topics in structured form.
The Takeaway
Go middleware is just function composition. The Chain helper keeps the wiring explicit. Putting authentication before rate limiting means authenticated requests can have looser limits; putting it after means you are rate-limiting unauthenticated traffic first, which is usually what you want for public APIs.
The cleanup goroutine in the rate limiter is the one piece developers most often forget — without it, a high-traffic service accumulates thousands of per-IP limiters and eventually runs out of memory.
Write each middleware with a single responsibility. Test them in isolation with httptest. Compose them at the route level so you can see exactly what policy applies to each endpoint.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.