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.
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:
- Recovery: catches panics anywhere inside and returns 500.
- Request ID: tags the request so every log line can be correlated.
- Logging: records method, path, status and duration after the inner layers finish.
- Auth: rejects unauthenticated requests early and stores the user in the context.
- 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
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
nextruns inward, afternextruns outward. - Order: recovery, request ID, logging, auth, then the rest.
- Pass request-scoped values with
context.WithValueand a private key type. - Wrap
ResponseWriterto capture status codes; usedeferfor 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.