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.