How to Implement Middleware in Go

Middleware is a function that intercepts HTTP requests before they reach your final handler, allowing you to execute common logic across multiple routes. Think of middleware as a pipeline where each...

Key Insights

  • Middleware in Go follows a simple pattern: functions that take an http.Handler and return an http.Handler, allowing you to wrap and modify request/response behavior
  • The order of middleware execution matters significantly—authentication should run before authorization, logging typically goes first, and recovery middleware should wrap everything
  • Use context.Context to pass request-scoped data between middleware layers rather than global variables or custom wrapper types

Introduction to Middleware in Go

Middleware is a function that intercepts HTTP requests before they reach your final handler, allowing you to execute common logic across multiple routes. Think of middleware as a pipeline where each piece can inspect, modify, or short-circuit the request/response cycle.

Common use cases include request logging, authentication, CORS headers, rate limiting, request timeouts, and panic recovery. Without middleware, you’d duplicate this logic across every handler or create unwieldy wrapper functions. Middleware provides a clean, composable solution.

The beauty of Go’s middleware pattern is its simplicity. Unlike frameworks in other languages that require complex inheritance or configuration, Go middleware is just functions wrapping functions. This makes it easy to understand, test, and compose.

The Basic Middleware Pattern

Go middleware follows a consistent signature: a function that accepts an http.Handler and returns an http.Handler. This allows middleware to wrap the next handler in the chain, execute code before and after it, or prevent it from running entirely.

Here’s the simplest possible middleware:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

This middleware logs the HTTP method and path, then calls next.ServeHTTP() to pass control to the next handler. The http.HandlerFunc type adapter converts our function into an http.Handler interface.

To use it:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", handleUsers)
    
    wrappedMux := loggingMiddleware(mux)
    http.ListenAndServe(":8080", wrappedMux)
}

Building Common Middleware Functions

Let’s implement several practical middleware functions you’ll use in real applications.

Request Logging with Timestamps:

func requestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        next.ServeHTTP(w, r)
        
        duration := time.Since(start)
        log.Printf(
            "%s %s %s %v",
            r.RemoteAddr,
            r.Method,
            r.URL.Path,
            duration,
        )
    })
}

Authentication Check:

func requireAuth(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, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        // Validate token (simplified)
        if !isValidToken(token) {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

Request Timeout Enforcement:

func timeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), timeout)
            defer cancel()
            
            r = r.WithContext(ctx)
            next.ServeHTTP(w, r)
        })
    }
}

CORS and Security Headers:

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("X-XSS-Protection", "1; mode=block")
        w.Header().Set("Access-Control-Allow-Origin", "*")
        
        next.ServeHTTP(w, r)
    })
}

Chaining Multiple Middleware

Middleware order matters. Generally, you want this order: recovery (panic handling) → logging → security headers → authentication → authorization → business logic.

Manual chaining works but gets verbose:

handler := loggingMiddleware(
    securityHeaders(
        requireAuth(
            http.HandlerFunc(handleUsers),
        ),
    ),
)

A cleaner approach uses a helper function:

func chainMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
    for i := len(middleware) - 1; i >= 0; i-- {
        h = middleware[i](h)
    }
    return h
}

// Usage
handler := chainMiddleware(
    http.HandlerFunc(handleUsers),
    requestLogger,
    securityHeaders,
    requireAuth,
)

Notice we iterate backwards through the middleware slice. This ensures the first middleware in the list executes first, which matches intuitive expectations.

Creating a Reusable Middleware Stack

For larger applications, create a middleware stack that’s reusable across routes:

type MiddlewareStack struct {
    middleware []func(http.Handler) http.Handler
}

func NewMiddlewareStack() *MiddlewareStack {
    return &MiddlewareStack{
        middleware: make([]func(http.Handler) http.Handler, 0),
    }
}

func (m *MiddlewareStack) Use(middleware func(http.Handler) http.Handler) {
    m.middleware = append(m.middleware, middleware)
}

func (m *MiddlewareStack) Then(h http.Handler) http.Handler {
    for i := len(m.middleware) - 1; i >= 0; i-- {
        h = m.middleware[i](h)
    }
    return h
}

Usage becomes much cleaner:

func main() {
    stack := NewMiddlewareStack()
    stack.Use(requestLogger)
    stack.Use(securityHeaders)
    stack.Use(timeoutMiddleware(5 * time.Second))
    
    mux := http.NewServeMux()
    mux.Handle("/api/users", stack.Then(http.HandlerFunc(handleUsers)))
    mux.Handle("/api/posts", stack.Then(requireAuth(http.HandlerFunc(handlePosts))))
    
    http.ListenAndServe(":8080", mux)
}

Advanced Patterns and Context Usage

Use context.Context to pass data between middleware layers. This is the idiomatic Go approach for request-scoped values.

type contextKey string

const userContextKey contextKey = "user"

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        
        user, err := validateTokenAndGetUser(token)
        if err != nil {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        // Store user in context
        ctx := context.WithValue(r.Context(), userContextKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    // Retrieve user from context
    user, ok := r.Context().Value(userContextKey).(*User)
    if !ok {
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }
    
    fmt.Fprintf(w, "Hello, %s", user.Name)
}

For conditional middleware execution:

func conditionalMiddleware(condition func(*http.Request) bool, middleware func(http.Handler) http.Handler) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if condition(r) {
                middleware(next).ServeHTTP(w, r)
            } else {
                next.ServeHTTP(w, r)
            }
        })
    }
}

// Only apply auth to /api/* routes
authOnlyForAPI := conditionalMiddleware(
    func(r *http.Request) bool {
        return strings.HasPrefix(r.URL.Path, "/api/")
    },
    requireAuth,
)

Best Practices and Common Pitfalls

Order matters. Recovery middleware should wrap everything to catch panics. Logging should come early to capture all requests. Authentication before authorization. Set response headers before writing the response body.

Don’t overuse context. Only store request-scoped data like user information, request IDs, or trace data. Don’t use it for optional parameters or configuration.

Avoid middleware bloat. Not every cross-cutting concern needs middleware. If logic only applies to one or two routes, put it in the handler. Middleware should be truly reusable.

Consider performance. Each middleware adds overhead. For high-traffic applications, profile your middleware stack. Simple operations like header setting are fine, but avoid expensive operations in middleware that runs on every request.

Test middleware independently. Write unit tests for each middleware function using httptest.ResponseRecorder:

func TestAuthMiddleware(t *testing.T) {
    handler := requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))
    
    req := httptest.NewRequest("GET", "/api/users", nil)
    rec := httptest.NewRecorder()
    
    handler.ServeHTTP(rec, req)
    
    if rec.Code != http.StatusUnauthorized {
        t.Errorf("expected 401, got %d", rec.Code)
    }
}

Use third-party packages judiciously. For simple middleware, write your own—it’s only a few lines. For complex needs like JWT validation, rate limiting with distributed state, or sophisticated CORS handling, established packages like golang-jwt/jwt, throttled, or rs/cors save time and bugs.

The middleware pattern in Go is powerful because it’s simple. Master these fundamentals, and you’ll write cleaner, more maintainable HTTP services.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.