Middlewares in Go: logging, auth, errors and recovery

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

Cover for Middlewares in Go: logging, auth, errors and recovery

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.Error and returns without calling next.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 none or 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:

  1. Recovery wraps everything. If any middleware or handler panics, it captures it.
  2. RequestID generates the ID before the rest of the middlewares need it.
  3. Auth validates the token. If it fails, neither Logging nor the handler executes.
  4. 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 of next.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() or c.AbortWithStatusJSON() instead of returning without calling next.
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:

  1. Create a dummy handler (that simulates the behaviour you want to test).
  2. Apply the middleware.
  3. Use httptest.NewRequest and httptest.NewRecorder.
  4. 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.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved