Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · Building Production APIs in Go

Middleware: Logging, Auth, Recovery and Request IDs

Write the middleware every API needs and learn the onion model: each layer runs code before and after the next one. Order matters: recovery first, then request IDs, logging, auth and rate limits.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A middleware stack for the books API: panic recovery, a request ID, an access log with status and duration, and token authentication that puts the user into the request context for handlers to read.

The idea

Middleware wraps handlers like layers of an onion. A request passes inward through every layer to the handler, and the response passes back outward:

recover → requestID → log → auth → handler
recover ← requestID ← log ← auth ←

Typical order, outermost first:

  1. Recovery: catches panics anywhere inside and returns 500.
  2. Request ID: tags the request so every log line can be correlated.
  3. Logging: records method, path, status and duration after the inner layers finish.
  4. Auth: rejects unauthenticated requests early and stores the user in the context.
  5. Rate limiting, CORS, timeouts as needed.

To pass data to handlers in net/http, use context.WithValue with an unexported key type (so packages can't collide), and a helper to read it. To record the status code, wrap the ResponseWriter.

Gin, Echo and Fiber have their own middleware signatures, but the ideas are identical.

The code

An onion of middleware with net/http
package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

type ctxKey int

const userKey ctxKey = iota

func UserFrom(ctx context.Context) (string, bool) {
	u, ok := ctx.Value(userKey).(string)
	return u, ok
}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func recoverer(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				fmt.Println("   [recover]", err)
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

func requestID(next http.Handler) http.Handler {
	n := 0
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n++
		w.Header().Set("X-Request-ID", fmt.Sprintf("req-%d", n))
		next.ServeHTTP(w, r)
	})
}

func accessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r) // inner layers run here
		elapsed := time.Since(start) < time.Second
		fmt.Printf("   [log] %s %s %s -> %d (fast: %v)\n", w.Header().Get("X-Request-ID"), r.Method, r.URL.Path, rec.status, elapsed)
	})
}

func auth(next http.Handler) http.Handler {
	tokens := map[string]string{"token-ana": "ana", "token-ben": "ben"}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, ok := tokens[r.Header.Get("Authorization")]
		if !ok {
			http.Error(w, "unauthorized", http.StatusUnauthorized) // stop here: the handler never runs
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, user)))
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
		user, _ := UserFrom(r.Context())
		fmt.Fprintf(w, "hello %s", user)
	})
	mux.HandleFunc("GET /crash", func(w http.ResponseWriter, r *http.Request) {
		var books map[string]int
		books["x"]++ // panics: assignment to a nil map
	})
	app := recoverer(requestID(accessLog(auth(mux))))

	for _, tc := range []struct{ path, token string }{{"/me", "token-ana"}, {"/me", ""}, {"/crash", "token-ben"}} {
		req := httptest.NewRequest("GET", tc.path, nil)
		req.Header.Set("Authorization", tc.token)
		rec := httptest.NewRecorder()
		app.ServeHTTP(rec, req)
		fmt.Printf("GET %s -> %d %q\n", tc.path, rec.Code, rec.Body.String())
	}
}

Output

   [log] req-1 GET /me -> 200 (fast: true)
GET /me -> 200 "hello ana"
   [log] req-2 GET /me -> 401 (fast: true)
GET /me -> 401 "unauthorized\n"
   [recover] assignment to entry in nil map
GET /crash -> 500 "internal error\n"

Run this on your computer — see “Get Started” for setup.

Notice the crash: recovery is the outermost layer, so it catches the panic, but the access log (inside it) never printed for that request because the panic skipped the code after next.ServeHTTP. Logging libraries solve this with defer in the logging middleware.

Common mistakes

  • Wrong order. Auth before logging means rejected requests aren't logged; recovery inside other middleware misses their panics.
  • Context keys as plain strings. Use an unexported type to avoid collisions.
  • Not recording the status code. Wrap the ResponseWriter (or use your framework's response object).
  • Doing work after writing the response that the client is waiting for. Keep middleware fast.
  • Logging secrets (Authorization headers, passwords in bodies).

Key points

  • Middleware = layers around the handler; code before next runs inward, after next runs outward.
  • Order: recovery, request ID, logging, auth, then the rest.
  • Pass request-scoped values with context.WithValue and a private key type.
  • Wrap ResponseWriter to capture status codes; use defer for logs that must always run.

Further reading: Go blog: Go concurrency patterns: Context, net/http Handler.

Practice questions

In what order should common HTTP middleware run, and why?

Answer

Outermost first: recovery (so it catches panics from everything inside), request ID (so all later logs carry it), logging (to time and record every request, including rejected ones), then auth and rate limiting, then the handler.

How do you pass the authenticated user from middleware to a handler in net/http?

Answer

Store it in the request context with context.WithValue using an unexported key type, call next with r.WithContext(ctx), and read it in the handler through a typed helper such as UserFrom(ctx).

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?