Every production HTTP service needs cross-cutting concerns handled before requests reach the business logic: authentication, authorization, rate limiting, CORS, request logging, error recovery, and metrics collection. Implementing these directly in handlers creates duplication, tight coupling, and the inevitable bug where someone adds a new endpoint and forgets to check permissions. Middleware solves this by wrapping handlers in composable layers that each handle one concern.
Go’s http.Handler interface—a single method, ServeHTTP(ResponseWriter, *Request)—makes middleware composition natural. A middleware is simply a function that takes a handler and returns a new handler. The platform builds its entire request pipeline from middleware: logging wraps recovery wraps CORS wraps auth wraps RBAC wraps rate limiting wraps the actual handler. Each layer can inspect the request, modify the context, short-circuit the chain, or observe the response.
The key insight is that middleware ordering matters. Auth must run before RBAC (you can’t check permissions without knowing who the user is). Rate limiting should run after auth (so you can rate-limit by user, not just IP). Recovery must be outermost (so panics in any middleware get caught). Getting the order wrong creates subtle bugs that only manifest under specific request patterns.
In Go, a middleware is a function with the signature func(http.Handler) http.Handler. It receives the next handler in the chain, returns a new handler that does its work before and/or after calling the next handler. This is the decorator pattern applied to HTTP handling, and it composes naturally: Chain(logging, recovery, auth, handler) produces a single handler where each middleware wraps the next.
Middleware communicates with downstream handlers through context.Context. The auth middleware extracts the user identity from the JWT and stores it in the context. The RBAC middleware reads the identity from context to check permissions. The handler reads the identity from context to scope database queries. This avoids global state and keeps the middleware chain thread-safe.
Any middleware can terminate the chain early by writing a response without calling the next handler. The auth middleware returns 401 if the token is invalid. The rate limiter returns 429 if the quota is exceeded. The CORS middleware handles preflight OPTIONS requests directly. Short-circuiting is what makes middleware useful for enforcement—the handler never sees requests that fail validation.
Some middleware needs to observe the response (status code, body size) after the handler runs. Since http.ResponseWriter doesn’t expose the status code after WriteHeader is called, middleware wraps it in a custom ResponseWriter that captures the status code and bytes written. The logging and metrics middleware use this to record response metadata.
The chain is assembled at server startup, not at request time. A Chain function takes a list of middleware and a final handler, composing them right-to-left so the first middleware in the list is the outermost wrapper. This means requests flow through middleware in list order, and responses flow back in reverse order.
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
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: build the full request pipeline
func BuildPipeline(handler http.Handler, deps *Dependencies) http.Handler {
return Chain(handler,
RecoveryMiddleware(deps.Logger),
RequestIDMiddleware(),
LoggingMiddleware(deps.Logger),
CORSMiddleware(deps.CORSConfig),
AuthMiddleware(deps.TokenVerifier),
RBACMiddleware(deps.PermissionStore),
RateLimitMiddleware(deps.RateLimiter),
)
}
The auth middleware extracts the Bearer token from the Authorization header, verifies it (JWT signature check, expiry validation), and injects the authenticated user into the request context. If the token is missing or invalid, it short-circuits with a 401. Routes that don’t require authentication use a separate middleware group that skips this step.
func AuthMiddleware(verifier TokenVerifier) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
http.Error(w, "missing authorization header", http.StatusUnauthorized)
return
}
claims, err := verifier.Verify(r.Context(), token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userContextKey, &User{
ID: claims.Subject,
Email: claims.Email,
Roles: claims.Roles,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
To log response status codes and sizes, the logging middleware wraps http.ResponseWriter in a struct that intercepts WriteHeader and Write calls. This wrapper is essential for any middleware that needs to observe what the handler wrote.
type responseCapture struct {
http.ResponseWriter
statusCode int
bytesWritten int
}
func (rc *responseCapture) WriteHeader(code int) {
rc.statusCode = code
rc.ResponseWriter.WriteHeader(code)
}
func (rc *responseCapture) Write(b []byte) (int, error) {
n, err := rc.ResponseWriter.Write(b)
rc.bytesWritten += n
return n, err
}
func LoggingMiddleware(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rc := &responseCapture{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rc, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rc.statusCode,
"bytes", rc.bytesWritten,
"duration", time.Since(start),
"request_id", RequestIDFrom(r.Context()),
)
})
}
}
CORS middleware handles preflight OPTIONS requests and sets the appropriate response headers for cross-origin requests. It short-circuits on preflight, returning 204 directly without reaching the handler. For actual requests, it adds the CORS headers and passes through to the next handler.
func CORSMiddleware(cfg CORSConfig) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !cfg.IsAllowedOrigin(origin) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin")
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", cfg.AllowedMethods())
w.Header().Set("Access-Control-Allow-Headers", cfg.AllowedHeaders())
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
The platform’s API server uses middleware chains at two levels. The global chain applies to all routes: recovery, request ID, logging, and CORS. Route-group chains apply to specific paths: the /api/v1/ group adds auth, RBAC, and rate limiting, while the /health and /metrics routes skip authentication entirely. This layering means public endpoints don’t pay the cost of token verification, and internal endpoints can add extra security layers without affecting the public API.
The middleware also feeds the observability system. The logging middleware emits structured logs for every request with timing, status codes, and request IDs. The metrics middleware exports Prometheus histograms for latency and counters for status codes. The tracing middleware creates OpenTelemetry spans that propagate through downstream service calls. All three observe the same request without knowing about each other.
Group middleware by the routes they protect. Public routes get the minimal chain (recovery, logging, CORS). Authenticated routes add auth and rate limiting. Admin routes add RBAC checks for admin roles. This avoids applying expensive middleware to routes that don’t need it and keeps the routing configuration readable.
The recovery middleware is always outermost. It defers a recover() call that catches panics from any downstream middleware or handler, logs the stack trace, and returns a 500 response instead of crashing the server. Without this, a single nil-pointer dereference in a handler would bring down the entire process.
Generate a unique request ID (UUID or ULID) in the request ID middleware and store it in context. Every subsequent middleware and handler includes this ID in log entries and error responses. When a user reports an error, the request ID lets you trace the entire request path through logs across multiple services.
next.ServeHTTP, causing double-writes. Always return after short-circuiting, or structure the code so next is only called in one branch.ResponseWriter for response capture, you may break WebSocket upgrades and streaming. Check if the original writer implements http.Hijacker or http.Flusher and delegate those interfaces too.type contextKey struct{}) to guarantee uniqueness./health means your load balancer needs credentials to check if the server is alive. Health and readiness endpoints should use a minimal middleware chain.