Skip to content
Codeloom
Go

HTTP Middleware Chaining Patterns in Go

Build composable HTTP middleware in Go for logging, auth, rate limiting, and recovery using standard library patterns.

·6 min read · By Codeloom
Intermediate 10 min read

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:

  1. RequestID - assign a trace ID first so all logs include it
  2. Logging - log the request with its ID
  3. Recovery - catch panics before they crash the process
  4. CORS - handle preflight before auth rejects it
  5. RateLimit - reject excess traffic before doing auth work
  6. Auth - validate credentials
  7. Handler - business logic

Summary

  • Middleware is func(http.Handler) http.Handler.
  • Use a Chain helper for readable, top-to-bottom middleware composition.
  • Wrap http.ResponseWriter to capture status codes for logging.
  • Place Recovery early in the chain to catch panics from all downstream code.
  • Use context.WithValue to pass request-scoped data (user, request ID) to handlers.
  • Return a Middleware closure for configurable middleware like rate limiters.
  • Apply middleware selectively by mounting different chains on different route groups.