HTTP Middleware Chaining Patterns in Go
Build composable HTTP middleware in Go for logging, auth, rate limiting, and recovery using standard library patterns.
What you'll learn
- ✓How to write HTTP middleware using the standard library
- ✓How to chain middleware in the correct order
- ✓Production patterns for logging, auth, recovery, and rate limiting
Prerequisites
- •Basic Go knowledge
- •Familiarity with net/http handlers
Middleware in Go is a function that wraps an http.Handler and returns a new http.Handler. It is the standard pattern for cross-cutting concerns like logging, authentication, and error recovery. This guide covers how to write, chain, and compose middleware using only the standard library.
The Middleware Signature
A middleware is a function that takes a handler and returns a handler.
type Middleware func(http.Handler) http.Handler
Here is the simplest possible middleware:
func PassThrough(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// do something before
next.ServeHTTP(w, r)
// do something after
})
}
Chaining Middleware
Manual Chaining
You can chain middleware by nesting calls.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", handleUsers)
handler := Logging(Recovery(Auth(mux)))
http.ListenAndServe(":8080", handler)
}
The execution order is: Logging -> Recovery -> Auth -> handler. The outermost middleware runs first.
A Chain Helper
For cleaner composition, build a helper that applies middleware in order.
func Chain(handler http.Handler, middlewares ...Middleware) http.Handler {
// Apply in reverse so the first middleware in the list runs first
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
Usage:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", handleUsers)
handler := Chain(mux,
Logging,
Recovery,
Auth,
CORS,
)
http.ListenAndServe(":8080", handler)
}
Now the order reads top-to-bottom: Logging runs first, then Recovery, then Auth, then CORS, then the handler.
Production Middleware: Logging
Log every request with method, path, status code, and duration.
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration", time.Since(start),
"remote_addr", r.RemoteAddr,
)
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
written bool
}
func (rw *responseWriter) WriteHeader(code int) {
if !rw.written {
rw.statusCode = code
rw.written = true
}
rw.ResponseWriter.WriteHeader(code)
}
The responseWriter wrapper captures the status code so the middleware can log it after the handler runs.
Production Middleware: Recovery
Catch panics in handlers and convert them to 500 responses instead of crashing the server.
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
slog.Error("panic recovered",
"error", fmt.Sprint(err),
"stack", string(buf[:n]),
"method", r.Method,
"path", r.URL.Path,
)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Place Recovery early in the chain (right after Logging) so it catches panics from all downstream middleware and handlers.
Production Middleware: Authentication
Extract and validate a token, then store the user in context.
type contextKey string
const userContextKey contextKey = "user"
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "missing authorization header", http.StatusUnauthorized)
return
}
token = strings.TrimPrefix(token, "Bearer ")
user, err := validateToken(token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func UserFromContext(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userContextKey).(*User)
return u, ok
}
Handlers downstream can retrieve the authenticated user:
func handleProfile(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(user)
}
Production Middleware: CORS
Handle cross-origin requests.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Production Middleware: Rate Limiting
Use golang.org/x/time/rate for a token bucket rate limiter.
func RateLimit(rps float64, burst int) Middleware {
limiter := rate.NewLimiter(rate.Limit(rps), burst)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
For per-client limiting, use a map of limiters keyed by IP:
func PerClientRateLimit(rps float64, burst int) Middleware {
var mu sync.Mutex
clients := make(map[string]*rate.Limiter)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
mu.Lock()
limiter, exists := clients[ip]
if !exists {
limiter = rate.NewLimiter(rate.Limit(rps), burst)
clients[ip] = limiter
}
mu.Unlock()
if !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
Production Middleware: Request ID
Assign a unique ID to every request for tracing.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Selective Middleware
Apply middleware only to certain routes.
func main() {
mux := http.NewServeMux()
// Public routes
mux.Handle("/health", Chain(http.HandlerFunc(handleHealth), Logging))
// Protected routes
api := http.NewServeMux()
api.HandleFunc("/users", handleUsers)
api.HandleFunc("/orders", handleOrders)
mux.Handle("/api/", http.StripPrefix("/api",
Chain(api, Logging, Recovery, Auth),
))
http.ListenAndServe(":8080", mux)
}
The /health endpoint gets only Logging. The /api/ routes get Logging, Recovery, and Auth.
Middleware Execution Order
Understanding execution order is critical. With Chain(handler, A, B, C):
Request -> A (before) -> B (before) -> C (before) -> Handler
Response <- A (after) <- B (after) <- C (after) <- Handler
A typical ordering for production:
- RequestID - assign a trace ID first so all logs include it
- Logging - log the request with its ID
- Recovery - catch panics before they crash the process
- CORS - handle preflight before auth rejects it
- RateLimit - reject excess traffic before doing auth work
- Auth - validate credentials
- Handler - business logic
Summary
- Middleware is
func(http.Handler) http.Handler. - Use a
Chainhelper for readable, top-to-bottom middleware composition. - Wrap
http.ResponseWriterto capture status codes for logging. - Place
Recoveryearly in the chain to catch panics from all downstream code. - Use
context.WithValueto pass request-scoped data (user, request ID) to handlers. - Return a
Middlewareclosure for configurable middleware like rate limiters. - Apply middleware selectively by mounting different chains on different route groups.
Related articles
- Go Go HTTP Server from Scratch
Build a production-ready HTTP server in Go using only the standard library: routing, middleware, timeouts, graceful shutdown, and structured logging.
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.
- Go Go Database Patterns with sqlx and pgx
Master Go database patterns using sqlx and pgx for PostgreSQL including connection pooling, transactions, batch operations, and repository design.
- Go Go Generics: Practical Patterns and Constraints
Learn practical Go generics patterns including type constraints, generic data structures, utility functions, and when to use generics vs interfaces.