Go HTTP Server Middleware: Patterns and Best Practices

Middleware is reusable logic that wraps HTTP handlers — authentication, logging, rate limiting, request ID injection, and panic recovery. Go's net/http package provides a minimal, composable foundatio

Introduction#

Middleware is reusable logic that wraps HTTP handlers — authentication, logging, rate limiting, request ID injection, and panic recovery. Go’s net/http package provides a minimal, composable foundation for building middleware chains. Understanding middleware patterns in Go helps you write cleaner, more maintainable HTTP servers.

The Handler Interface#

Everything in Go’s HTTP stack implements http.Handler:

1
2
3
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Middleware is a function that takes a Handler and returns a Handler:

1
type Middleware func(http.Handler) http.Handler

This simple shape makes middleware composable.

Basic Middleware Pattern#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package main

import (
    "log"
    "net/http"
    "time"
)

// Logger middleware logs method, path, duration, and status code
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap ResponseWriter to capture status code
        wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}

        next.ServeHTTP(wrapped, r)

        log.Printf(
            "method=%s path=%s status=%d duration=%s",
            r.Method,
            r.URL.Path,
            wrapped.status,
            time.Since(start),
        )
    })
}

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(status int) {
    rw.status = status
    rw.ResponseWriter.WriteHeader(status)
}

Middleware Chain#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Chain applies middleware in order: first middleware is outermost
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/items", itemsHandler)
    mux.HandleFunc("/api/users", usersHandler)

    // Middleware applied outside-in: Logger → Auth → RateLimit → handler
    handler := Chain(mux,
        Logger,
        Auth,
        RateLimit(100), // 100 requests/sec
        RequestID,
        Recovery,
    )

    http.ListenAndServe(":8080", handler)
}

Request ID Middleware#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "net/http"
)

type contextKey string

const RequestIDKey contextKey = "request_id"

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 == "" {
            b := make([]byte, 8)
            rand.Read(b)
            id = hex.EncodeToString(b)
        }

        ctx := context.WithValue(r.Context(), RequestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Retrieve in a handler
func itemsHandler(w http.ResponseWriter, r *http.Request) {
    reqID, _ := r.Context().Value(RequestIDKey).(string)
    log.Printf("request_id=%s handling items", reqID)
}

Authentication Middleware#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import (
    "net/http"
    "strings"

    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    UserID int    `json:"user_id"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "missing authorization header", http.StatusUnauthorized)
            return
        }

        tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
        claims := &Claims{}

        token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
            }
            return []byte(os.Getenv("JWT_SECRET")), nil
        })

        if err != nil || !token.Valid {
            http.Error(w, "invalid token", http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), "claims", claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Role-based authorization middleware
func RequireRole(role string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            claims, ok := r.Context().Value("claims").(*Claims)
            if !ok || claims.Role != role {
                http.Error(w, "forbidden", http.StatusForbidden)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Rate Limiting Middleware#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import (
    "net/http"
    "sync"
    "time"

    "golang.org/x/time/rate"
)

// Per-IP rate limiter
type IPRateLimiter struct {
    limiters sync.Map
    rate     rate.Limit
    burst    int
}

func NewIPRateLimiter(r rate.Limit, burst int) *IPRateLimiter {
    return &IPRateLimiter{rate: r, burst: burst}
}

func (l *IPRateLimiter) getLimiter(ip string) *rate.Limiter {
    v, ok := l.limiters.Load(ip)
    if !ok {
        limiter := rate.NewLimiter(l.rate, l.burst)
        l.limiters.Store(ip, limiter)
        return limiter
    }
    return v.(*rate.Limiter)
}

func RateLimitMiddleware(rps float64) Middleware {
    limiter := NewIPRateLimiter(rate.Limit(rps), int(rps))

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ip := r.RemoteAddr

            if !limiter.getLimiter(ip).Allow() {
                w.Header().Set("Retry-After", "1")
                http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

Panic Recovery Middleware#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import (
    "fmt"
    "net/http"
    "runtime/debug"
)

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                stack := debug.Stack()
                log.Printf("panic recovered: %v\n%s", err, stack)

                // Don't leak internal details to the client
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()

        next.ServeHTTP(w, r)
    })
}

Timeout Middleware#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import (
    "context"
    "net/http"
    "time"
)

func Timeout(d time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()

            done := make(chan struct{})
            go func() {
                next.ServeHTTP(w, r.WithContext(ctx))
                close(done)
            }()

            select {
            case <-done:
                return
            case <-ctx.Done():
                http.Error(w, "request timeout", http.StatusGatewayTimeout)
            }
        })
    }
}

CORS Middleware#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func CORS(allowedOrigins []string) Middleware {
    originSet := make(map[string]bool)
    for _, o := range allowedOrigins {
        originSet[o] = true
    }

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")

            if originSet[origin] || (len(allowedOrigins) == 1 && allowedOrigins[0] == "*") {
                w.Header().Set("Access-Control-Allow-Origin", origin)
                w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
                w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
                w.Header().Set("Access-Control-Max-Age", "86400")
            }

            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

Putting It Together#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func main() {
    mux := http.NewServeMux()

    // Public routes
    mux.HandleFunc("GET /health", healthHandler)

    // API routes with auth
    api := http.NewServeMux()
    api.HandleFunc("GET /api/items", itemsHandler)
    api.HandleFunc("POST /api/items", createItemHandler)

    adminMux := http.NewServeMux()
    adminMux.HandleFunc("DELETE /api/admin/users/{id}", deleteUserHandler)

    // Build handler chain
    publicHandler := Chain(mux, Logger, Recovery, RequestID)

    apiHandler := Chain(api, Logger, Recovery, RequestID,
        RateLimitMiddleware(100),
        Auth,
    )

    adminHandler := Chain(adminMux, Logger, Recovery, RequestID,
        Auth,
        RequireRole("admin"),
    )

    // Combine
    root := http.NewServeMux()
    root.Handle("/", publicHandler)
    root.Handle("/api/", apiHandler)
    root.Handle("/api/admin/", adminHandler)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      root,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    log.Fatal(server.ListenAndServe())
}

Conclusion#

Go HTTP middleware is composed from a single pattern: func(Handler) Handler. Build small, single-purpose middleware functions and chain them with a Chain helper. Keep middleware stateless where possible — pass per-request state via context.Context. Use a captured responseWriter to observe the status code written downstream. The standard library is sufficient for most production middleware; external routers like chi provide similar composition with additional routing capabilities.

Contents