Middlewares in Go: logging, auth, errors and recovery
How middlewares work in Go with examples of logging, authentication, request ID, panic recovery and metrics.

Middlewares in Go are the equivalent of Spring filters or Express middlewares. A simple concept: a function that wraps another function and executes logic before, after or around each HTTP request. When used well, they eliminate duplication and centralize cross-cutting concerns. When used poorly, they hide business logic in invisible layers and turn debugging into a nightmare.
In Go, a middleware is not a magic annotation or a framework. It is a function that receives a handler and returns another handler. Nothing more. And that simplicity is exactly what makes it so powerful.
What is a middleware in Go
A middleware in Go is a function that wraps an http.Handler or an http.HandlerFunc. Its job is to intercept the request before it reaches the real handler, execute some logic (logging, authentication, metrics), and decide whether the request continues or is cut short.
The pattern is always the same:
func MyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Logic before the handler
next.ServeHTTP(w, r)
// Logic after the handler
})
}That’s all there is to it. No interfaces to implement, no abstract classes, no registration in a container. A function that receives a handler, returns a handler, and in between does whatever you need.
The signature func(http.Handler) http.Handler is the standard convention. If you see this signature in any Go library, you know it is a middleware compatible with net/http. This matters because it means middlewares are composable: you can chain ten middlewares from different libraries and they all work together without adapters.
If you come from Spring, think of a HandlerInterceptor but without @Component, without @Order, without XML. If you come from Express, it is exactly the same concept but typed and without a next() you can forget to call… well, in Go you can also forget it, but the compiler at least helps you with the type.
Your first middleware: duration logging
The most useful middleware and the first one you should write in any project is one that records how long each request takes. It is simple, has immediate value in production, and serves as a template to understand the pattern.
package middleware
import (
"log/slog"
"net/http"
"time"
)
func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap the ResponseWriter to capture the status code
ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(ww, r)
logger.Info("request completed",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.statusCode),
slog.Duration("duration", time.Since(start)),
)
})
}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}There is an important detail here: the wrapping responseWriter. The standard http.ResponseWriter does not expose the status code after it has been written. You need to intercept the call to WriteHeader to capture it. You will use this pattern in many middlewares.
Also notice that the middleware receives a *slog.Logger as a parameter. This is dependency injection Go-style: you pass what you need as an argument. The middleware returns another function which is the actual middleware. This “factory function” pattern is standard when your middleware needs configuration.
To use it:
func main() {
logger := slog.Default()
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)
mux.HandleFunc("GET /users/{id}", handleGetUser)
handler := middleware.Logging(logger)(mux)
http.ListenAndServe(":8080", handler)
}Every request now generates a structured log with method, path, status code and duration. Without touching a single line of the handlers.
Request ID Middleware
In production, when you have hundreds of requests per second, you need to be able to trace a specific request through all your logs. The request ID is the standard way to do it: you generate a unique identifier for each request and propagate it through the context.
package middleware
import (
"context"
"net/http"
"github.com/google/uuid"
)
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) {
// If the client already sends a request ID, reuse it
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
// Add to context so other middlewares and handlers can use it
ctx := context.WithValue(r.Context(), RequestIDKey, id)
// Add to the response header so the client can trace it
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Helper to extract the request ID from context
func GetRequestID(ctx context.Context) string {
if id, ok := ctx.Value(RequestIDKey).(string); ok {
return id
}
return ""
}The use of context.WithValue is key here. Context in Go is the standard mechanism for passing values through the handler chain without polluting function signatures. The request ID is one of the most legitimate use cases for context.WithValue.
A detail worth mentioning: we use a private contextKey type as the context key. This avoids collisions with other packages that might use "request_id" as a key. It is an idiomatic Go convention you should always follow.
The GetRequestID helper is a pattern you will see repeated: a middleware injects a value into the context and exposes a public function to extract it. This way the code that consumes the value does not need to know the internal key.
Authentication middleware: JWT validation
Authentication is where middlewares truly shine. Instead of validating the token in every handler, you centralize the logic in a middleware that rejects unauthenticated requests before they reach your business code.
package middleware
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
const UserClaimsKey contextKey = "user_claims"
func Auth(secret []byte) func(http.Handler) http.Handler {
return func(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, `{"error": "missing authorization header"}`, http.StatusUnauthorized)
return
}
// We expect "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, `{"error": "invalid authorization format"}`, http.StatusUnauthorized)
return
}
token, err := jwt.ParseWithClaims(parts[1], &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return secret, nil
})
if err != nil {
http.Error(w, `{"error": "invalid token"}`, http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
http.Error(w, `{"error": "invalid token claims"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUserClaims(ctx context.Context) *Claims {
if claims, ok := ctx.Value(UserClaimsKey).(*Claims); ok {
return claims
}
return nil
}Key points:
- The middleware cuts the chain if the token is not valid. It calls
http.Errorandreturns without callingnext.ServeHTTP. This is key: the handler never executes for unauthenticated requests. - We validate the signing method. We verify that the token uses HMAC and not another algorithm. This prevents algorithm confusion attacks where an attacker changes the JWT header to
noneor to RSA. - Claims go into the context. Any subsequent handler can call
GetUserClaims(r.Context())to access the authenticated user without parsing the token again.
For a more complete implementation of an API with authentication, check out REST API with Go.
Role-based authorization middleware
Once you have authentication, authorization becomes trivial as another middleware:
func RequireRole(roles ...string) func(http.Handler) http.Handler {
allowed := make(map[string]bool)
for _, r := range roles {
allowed[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetUserClaims(r.Context())
if claims == nil {
http.Error(w, `{"error": "unauthorized"}`, http.StatusUnauthorized)
return
}
if !allowed[claims.Role] {
http.Error(w, `{"error": "forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}This lets you protect specific routes:
adminOnly := middleware.RequireRole("admin")
mux.Handle("DELETE /users/{id}", adminOnly(http.HandlerFunc(handleDeleteUser)))Separating authentication and authorization into separate middlewares is a good practice. Each has a clear responsibility and you can combine them flexibly.
Recovery middleware: capturing panics
A panic in a handler should not bring down your entire server. The recovery middleware captures panics, returns a 500 to the client and logs the error so you can investigate. If you want to understand the full mechanism of defer, panic and recover, I explain it in errors in Go.
package middleware
import (
"fmt"
"log/slog"
"net/http"
"runtime/debug"
)
func Recovery(logger *slog.Logger) func(http.Handler) http.Handler {
return func(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()
logger.Error("panic recovered",
slog.String("error", fmt.Sprintf("%v", err)),
slog.String("stack", string(stack)),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
)
http.Error(w,
`{"error": "internal server error"}`,
http.StatusInternalServerError,
)
}
}()
next.ServeHTTP(w, r)
})
}
}The key is the defer with recover(). When a panic occurs inside next.ServeHTTP, the defer executes, recover() captures the panic value, and instead of the program terminating, we return a controlled 500 error.
We include the full stack trace with debug.Stack(). In production this is gold: without the stack trace, a recovered panic is a mystery. With it, you can go directly to the line that caused it.
A recovery middleware is NOT an excuse to not handle errors correctly. It is a safety net, not your error-handling strategy. If your code panics frequently, the problem is in the code, not in the absence of recovery.
CORS Middleware
If your API is consumed by a frontend on a different domain, you need CORS. You can use a library like rs/cors, but understanding how it works at the middleware level is useful.
package middleware
import "net/http"
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
MaxAge int
}
func CORS(config CORSConfig) func(http.Handler) http.Handler {
origins := make(map[string]bool)
for _, o := range config.AllowedOrigins {
origins[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 origins["*"] || origins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods",
joinStrings(config.AllowedMethods))
w.Header().Set("Access-Control-Allow-Headers",
joinStrings(config.AllowedHeaders))
if config.MaxAge > 0 {
w.Header().Set("Access-Control-Max-Age",
fmt.Sprintf("%d", config.MaxAge))
}
// Preflight requests: respond directly
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
func joinStrings(ss []string) string {
result := ""
for i, s := range ss {
if i > 0 {
result += ", "
}
result += s
}
return result
}The critical point is handling OPTIONS (preflight) requests. The browser sends an OPTIONS request before the real request to verify whether the server allows cross-origin access. If you do not handle it, your API will reject all requests from the frontend.
In production, I would recommend using github.com/rs/cors instead of implementing CORS from scratch. CORS rules are more subtle than they appear and an incomplete implementation can create security holes. But understanding what the middleware does underneath helps you debug problems when requests fail for no apparent reason.
Chaining middlewares: order matters
When you have several middlewares, the order in which you chain them determines the execution order. And execution order matters a lot.
func main() {
logger := slog.Default()
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)
mux.HandleFunc("GET /users/{id}", handleGetUser)
mux.HandleFunc("POST /users", handleCreateUser)
// Application order is from outside in.
// The first middleware in the chain is the first to execute.
var handler http.Handler = mux
handler = middleware.Logging(logger)(handler) // 3. Log each request
handler = middleware.Auth(jwtSecret)(handler) // 2. Verify authentication
handler = middleware.RequestID(handler) // 1. Generate request ID
handler = middleware.Recovery(logger)(handler) // 0. Capture panics
http.ListenAndServe(":8080", handler)
}Read the chain bottom to top: Recovery is the outermost (executes first), followed by RequestID, then Auth and finally Logging. This means:
- Recovery wraps everything. If any middleware or handler panics, it captures it.
- RequestID generates the ID before the rest of the middlewares need it.
- Auth validates the token. If it fails, neither Logging nor the handler executes.
- Logging records the request just before/after the real handler.
A helper for chaining
Writing the chain manually is tedious and error-prone. A simple helper solves this:
func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
// Apply in reverse order so the first middleware
// is the outermost (executes first)
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}Now the code is more readable:
handler := middleware.Chain(mux,
middleware.Recovery(logger),
middleware.RequestID,
middleware.Auth(jwtSecret),
middleware.Logging(logger),
)The first middleware in the list is the outermost. Much clearer.
Middlewares with Gin vs net/http
Everything so far has been with the standard net/http. If you use Gin, the concept is the same but the API is slightly different.
Middlewares in Gin
Gin uses its own gin.HandlerFunc type instead of http.Handler:
func LoggingMiddleware(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
// Execute the next handler
c.Next()
logger.Info("request completed",
slog.String("method", c.Request.Method),
slog.String("path", c.Request.URL.Path),
slog.Int("status", c.Writer.Status()),
slog.Duration("duration", time.Since(start)),
)
}
}The main differences:
- In Gin you use
c.Next()instead ofnext.ServeHTTP(w, r). - The status code is already available in
c.Writer.Status()without needing to wrap the writer. - To abort the chain you use
c.Abort()orc.AbortWithStatusJSON()instead ofreturning without callingnext.
func AuthMiddleware(secret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing authorization header",
})
return
}
// ... validate token ...
c.Set("user_claims", claims)
c.Next()
}
}Registering middlewares in Gin
Gin has dedicated methods for registering middlewares globally or per route group:
func main() {
r := gin.New() // gin.New() without default middlewares
// Global middlewares
r.Use(gin.Recovery())
r.Use(LoggingMiddleware(slog.Default()))
// Public group
public := r.Group("/api")
{
public.GET("/health", handleHealth)
public.POST("/login", handleLogin)
}
// Authenticated group
auth := r.Group("/api")
auth.Use(AuthMiddleware(jwtSecret))
{
auth.GET("/users/:id", handleGetUser)
auth.POST("/users", handleCreateUser)
}
// Admin group
admin := r.Group("/api/admin")
admin.Use(AuthMiddleware(jwtSecret))
admin.Use(RequireRoleMiddleware("admin"))
{
admin.DELETE("/users/:id", handleDeleteUser)
}
r.Run(":8080")
}This is more expressive than with pure net/http. Route groups with specific middlewares are one of Gin’s strong points. In net/http you can achieve the same, but it requires more manual code.
When to use each
- net/http: if your project is small, if you want zero dependencies, or if the standard ecosystem middlewares are enough for you. Since Go 1.22, the standard mux supports patterns like
GET /users/{id}, which reduces the need for an external router. - Gin: if you need advanced routing with groups, per-group middleware, parameter binding, or you already have a project with Gin. More detail at Go and Gin.
When middlewares help vs when they hide too much
Middlewares are a powerful tool. And like every powerful tool, they can be misused. After maintaining several APIs in production, I have clear opinions about where the limits are.
Good uses of middlewares
- Logging and metrics: the cross-cutting concern par excellence. Every request should generate logs and metrics, and no handler should have to worry about it.
- Authentication: validating tokens is an infrastructure concern, not a business one. Centralize it.
- Request ID and traceability: injecting correlation IDs into context is exactly what middlewares do well.
- Recovery: the safety net against panics. It should always be present.
- CORS: configuration that applies to all requests.
- Rate limiting: traffic control at the infrastructure level.
- Compression: gzip of responses that has nothing to do with your business logic.
Bad uses of middlewares
- Business logic: if your middleware decides whether a user can access a specific resource based on complex business rules, that is not a middleware. It is business logic hidden in a layer that nobody looks at when debugging.
- Data transformation: if your middleware modifies the request body or the response in non-trivial ways, you are creating invisible magic. The next developer who reads the handler will not understand why the data is not what they expect.
- Middlewares with shared mutable state: a middleware that writes to a shared map without synchronization is a race condition waiting to explode.
- Too many middlewares in the chain: if you have fifteen middlewares, your request passes through fifteen layers of indirection before reaching the handler. Each layer adds complexity to debugging. More than five or six global middlewares should make you think about whether some should be specific to certain routes.
The rule is simple: if the middleware handles an infrastructure concern that applies to many routes, it is a good middleware. If it handles logic that only applies to a specific handler, put it in the handler.
Testing middlewares
Middlewares are pure functions in the sense that they receive a handler and return a handler. This makes them surprisingly easy to test.
Testing the logging middleware
func TestLoggingMiddleware(t *testing.T) {
// Dummy handler that returns 200
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
// Apply middleware
wrapped := Logging(logger)(handler)
// Create request and recorder
req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
// Verify response
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rec.Code)
}
// Verify that a log was generated
if buf.Len() == 0 {
t.Error("expected log output, got none")
}
logOutput := buf.String()
if !strings.Contains(logOutput, "/users/123") {
t.Errorf("expected log to contain path, got: %s", logOutput)
}
}Testing the authentication middleware
func TestAuthMiddleware_ValidToken(t *testing.T) {
secret := []byte("test-secret")
// Create a valid token
claims := &Claims{
UserID: "user-1",
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(secret)
if err != nil {
t.Fatal(err)
}
// Handler that verifies the claims are in the context
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userClaims := GetUserClaims(r.Context())
if userClaims == nil {
t.Error("expected claims in context")
return
}
if userClaims.UserID != "user-1" {
t.Errorf("expected user-1, got %s", userClaims.UserID)
}
w.WriteHeader(http.StatusOK)
})
wrapped := Auth(secret)(handler)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+tokenString)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
func TestAuthMiddleware_MissingHeader(t *testing.T) {
secret := []byte("test-secret")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not be called")
})
wrapped := Auth(secret)(handler)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}Testing the recovery middleware
func TestRecoveryMiddleware(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("something went terribly wrong")
})
wrapped := Recovery(logger)(handler)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
// Should not panic
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", rec.Code)
}
if !strings.Contains(buf.String(), "something went terribly wrong") {
t.Error("expected panic message in logs")
}
}The pattern is always the same:
- Create a dummy handler (that simulates the behaviour you want to test).
- Apply the middleware.
- Use
httptest.NewRequestandhttptest.NewRecorder. - Verify the result.
httptest is part of Go’s standard library. You do not need external mocks or testing frameworks. Everything comes included. If you want to go deeper into testing in Go, I have a dedicated article: testing in Go.
Functions that wrap functions, nothing more
Middlewares in Go are functions that wrap handlers. No magic, no annotations, no mandatory framework. And that simplicity is precisely what makes them so effective. The signature func(http.Handler) http.Handler is all you need for logging, request IDs, authentication, authorization, recovery and CORS. They chain, compose and test with httptest, and work the same in net/http as in Gin with minimal API changes.
The rule that will save you trouble: use middlewares for cross-cutting infrastructure concerns. If something smells like business logic, it is not a middleware. It is code that should be in a handler or a service, visible and explicit.
Start with logging, request ID and recovery. Add authentication when you need it. And resist the temptation to put more logic than necessary into the middleware chain. Your future self debugging at three in the morning will thank you.


