Go middleware is a function that takes an http.Handler and returns a new one: func(http.Handler) http.Handler. The returned handler does its work before or after calling the one it wraps. That signature is the whole pattern. You don't need a framework for it, and since Go 1.22 you don't need a third-party router either, because http.ServeMux matches methods and path wildcards (Go 1.22 release notes).
Writing one takes a few lines. In production, the best practices are about the details around it: the order of the stack, a ResponseWriter wrapper that doesn't break streaming, panic recovery that reports failures correctly, and context values that can't collide. This guide builds the middleware stack for an orders and payments API one layer at a time. Every snippet compiles on Go 1.27, and the tests pass with go test -race.
TL;DR
- Write middleware as
func(http.Handler) http.Handlerand chain it with a ten-line helper. Global middleware wraps the mux. Per-route middleware wraps the handler you register. - Order matters. Put request ID and logging outside panic recovery, so the log line records the 500 that recovery writes and carries the request ID.
- A
ResponseWriterwrapper needs anUnwrap() http.ResponseWritermethod. Without it,http.ResponseControllercan't flush or set deadlines through your middleware. - Recovery must re-panic
http.ErrAbortHandler, and it must abort instead of writing a 500 once the handler has already sent a status. - Set
ReadHeaderTimeouton the server, give each route a context deadline, and cap bodies withhttp.MaxBytesReader. - Auth middleware puts the principal in the context. It returns 401 when the caller is unknown and 403 when the caller lacks permission.
- Key rate limits on an IP your own proxy added, not on the leftmost
X-Forwarded-Forvalue.
What does Go HTTP middleware look like?
A middleware receives the next handler and returns a handler that calls it. Naming the type makes chains easier to read:
example.gogotype Middleware func(http.Handler) http.Handler // Chain wraps h so that the first middleware in the list runs first. func Chain(h http.Handler, mws ...Middleware) http.Handler { for i := len(mws) - 1; i >= 0; i-- { h = mws[i](h) } return h }
Chain applies the list backwards, so the first entry ends up outermost and sees the request first.
Inside each middleware, http.HandlerFunc turns a closure into a handler. That conversion works because HandlerFunc is a function type with a ServeHTTP method, which the Go func keyword guide covers along with closures. The Middleware type itself is just http.Handler in and out, and the Go interface keyword guide explains why a one-method interface like http.Handler composes so well.
Global vs per-route middleware on http.ServeMux
ServeMux has no Use method. Wrap the whole mux for middleware that every request needs, and wrap individual handlers for the rest:
example.gogofunc newServer(logger *slog.Logger, tokens TokenVerifier, limiter *RateLimiter) (*http.Server, error) { auth := requireAuth(tokens) mux := http.NewServeMux() mux.HandleFunc("GET /orders", listOrders) mux.Handle("GET /orders/{id}", auth(http.HandlerFunc(getOrder))) mux.Handle("POST /payments", Chain(http.HandlerFunc(createPayment), auth, requireScope("payments:write"), withDeadline(5*time.Second), )) csrf := http.NewCrossOriginProtection() if err := csrf.AddTrustedOrigin("https://admin.example.com"); err != nil { return nil, err } handler := Chain(mux, requestID, logRequests(logger), recoverPanics(logger), csrf.Handler, limiter.Middleware, limitBody(1<<20), ) return &http.Server{ Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, }, nil }
The public order list skips authentication, fetching one order needs a valid token, and creating a payment also needs a scope and gets a five-second budget. Because the per-route chain is part of the route registration, you can read who may call what from one screen of code.
Keep this wiring in one place, such as cmd/orders/server.go or an internal/httpapi package. Go project structure covers where that package belongs in a larger layout.
In what order should Go middleware run?
The first middleware in the chain wraps everything after it. It sees the request first and the response last. That means an outer layer can only observe what inner layers do, and an inner layer never sees what happens outside it. A good production order for an HTTP API is:
| # | Middleware | Why it sits here |
|---|---|---|
| 1 | Request ID | Every later layer, including the log line and the panic report, can read the ID. |
| 2 | Access logging | It wraps recovery, so it records the status that recovery wrote. |
| 3 | Panic recovery | It catches panics from every inner layer and the handler and turns them into a 500. |
| 4 | Cross-origin protection | It rejects cross-site browser writes before they cost any work. |
| 5 | Rate limit per client IP | It sheds abusive traffic before auth pays for a token lookup. |
| 6 | Body limit | It caps every request body before any handler reads it. |
| 7 | Auth and scopes, per route | Only routes that need a caller pay for verifying one. |
| 8 | Deadline, per route | Each route gets a time budget that matches its work. |
Many guides put recovery first so that it catches everything. These are the logs from one panicking handler run through both orders:
example.texttext# requestID → logRequests → recoverPanics level=ERROR msg="handler panic" panic="assignment to entry in nil map" request_id=U5UTAL72SXLQQQPQYWOHHEJVUO level=INFO msg=request method=GET path=/orders/ord_9 status=500 bytes=22 request_id=U5UTAL72SXLQQQPQYWOHHEJVUO # recoverPanics → requestID → logRequests level=INFO msg=request method=GET path=/orders/ord_9 status=200 bytes=0 request_id=NBJGZZOPM4YXB2FT3PS66BJE7K level=ERROR msg="handler panic" panic="assignment to entry in nil map"
In both cases the client gets a 500. With recovery outermost, the access log says 200, and the panic line has no request ID to join it to the request. The only code the inner recovery doesn't protect is the request ID and logging middleware, which are small. The net/http server also recovers any panic that escapes your handler and logs it, so nothing crashes the process (net/http Handler docs).
Wrapping http.ResponseWriter without breaking Flush
Logging needs the status code and byte count, and http.ResponseWriter doesn't expose either. The usual fix is a wrapper that records them:
example.gogotype statusWriter struct { http.ResponseWriter status int bytes int } func (w *statusWriter) WriteHeader(code int) { if w.status == 0 { w.status = code } w.ResponseWriter.WriteHeader(code) } func (w *statusWriter) Write(b []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } n, err := w.ResponseWriter.Write(b) w.bytes += n return n, err } // Unwrap lets http.ResponseController reach Flush, Hijack and deadlines. func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
Embedding http.ResponseWriter promotes only the three methods of that interface, a rule the Go struct keyword guide covers under embedding. The real writer underneath also implements http.Flusher and http.Hijacker, but your wrapper doesn't, so a server-sent events handler that does w.(http.Flusher) stops streaming as soon as logging wraps it.
Adding Unwrap fixes this. Since Go 1.20, http.ResponseController calls it to find the original writer. Handlers then call http.NewResponseController(w).Flush() instead of type-asserting. Without Unwrap, that call returns an error matching http.ErrNotSupported, and the test later in this guide fails.
The same method keeps per-request write deadlines working. A CSV export can outlive the server's 30-second WriteTimeout, which the docs say doesn't "let Handlers make decisions on a per-request basis" (http.Server). ResponseController does:
example.gogofunc exportOrders(w http.ResponseWriter, r *http.Request) { rc := http.NewResponseController(w) if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Minute)); err != nil { slog.WarnContext(r.Context(), "cannot extend write deadline", "error", err) } w.Header().Set("Content-Type", "text/csv") // stream rows from the database, flushing every few hundred rows }
How do you recover from panics in Go middleware?
A recovery middleware defers a recover, logs the stack and writes a 500. A production version needs two more details:
example.gogofunc recoverPanics(logger *slog.Logger) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sw := &statusWriter{ResponseWriter: w} defer func() { v := recover() if v == nil { return } if v == http.ErrAbortHandler { panic(v) } logger.ErrorContext(r.Context(), "handler panic", "panic", v, "stack", string(debug.Stack())) if sw.status != 0 { // The status line is already on the wire. Abort the // connection so the client doesn't take a cut-off // body for a complete 200. panic(http.ErrAbortHandler) } http.Error(w, "internal server error", http.StatusInternalServerError) }() next.ServeHTTP(sw, r) }) } }
The first detail is http.ErrAbortHandler. It is a sentinel that code panics with on purpose to abort a response, and httputil.ReverseProxy uses it when the backend fails mid-copy. The docs say that "panicking with ErrAbortHandler also suppresses logging of a stack trace to the server's error log" (net/http). If your middleware swallows it, a routine proxy abort turns into an error log with a stack trace and an attempt to write a 500 into a response that is already half sent.
The second detail is the sw.status != 0 check. Once a handler has written a status, calling http.Error can't change it. net/http logs "superfluous response.WriteHeader call" and appends the error text to whatever body was already streaming. A JSON array that stops halfway and ends in internal server error still arrives as a 200. Re-panicking with ErrAbortHandler makes the server close the connection or reset the HTTP/2 stream, so the client sees a failed request instead of a corrupt success.
How do you pass request IDs and other values through context?
context.WithValue carries request-scoped data from middleware to handlers. The docs say the key "should not be of type string or any other built-in type to avoid collisions between packages using context" (context). An unexported empty struct type can't collide with anything, because no other package can name it:
example.gogotype requestIDKey struct{} func requestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := rand.Text() w.Header().Set("X-Request-ID", id) ctx := context.WithValue(r.Context(), requestIDKey{}, id) next.ServeHTTP(w, r.WithContext(ctx)) }) } func RequestIDFrom(ctx context.Context) (string, bool) { id, ok := ctx.Value(requestIDKey{}).(string) return id, ok }
crypto/rand.Text, added in Go 1.24, returns a 26-character random base32 string, which is plenty for an ID. The same pattern carries the authenticated user later in this guide. Keep context values to data that belongs to the request, such as IDs and the caller. The context docs warn against using values for "passing optional parameters to functions". A database handle or a feature flag client belongs in a struct field of your handler.
Structured logging with slog
The request ID is only useful if every log line during the request carries it. Rather than passing it to each call, wrap the slog.Handler so it reads the ID from the context that InfoContext and friends receive:
example.gogotype requestIDHandler struct { slog.Handler } func (h requestIDHandler) Handle(ctx context.Context, rec slog.Record) error { if id, ok := RequestIDFrom(ctx); ok { rec.AddAttrs(slog.String("request_id", id)) } return h.Handler.Handle(ctx, rec) } func (h requestIDHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return requestIDHandler{h.Handler.WithAttrs(attrs)} } func (h requestIDHandler) WithGroup(name string) slog.Handler { return requestIDHandler{h.Handler.WithGroup(name)} }
Build the logger with slog.New(requestIDHandler{slog.NewJSONHandler(os.Stdout, nil)}). The two With methods matter. Embedding promotes the inner handler's WithAttrs, which returns the inner handler without your wrapper. The first logger.With("service", "orders") would then drop request IDs from every line after it.
The access log middleware uses the statusWriter from earlier and logs in a defer, so it still writes a line when a panic passes through it:
example.gogofunc logRequests(logger *slog.Logger) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w} defer func() { status := sw.status if status == 0 { status = http.StatusOK } logger.InfoContext(r.Context(), "request", "method", r.Method, "path", r.URL.Path, "status", status, "bytes", sw.bytes, "duration", time.Since(start), ) }() next.ServeHTTP(sw, r) }) } }
A handler that writes nothing gets a 200 from net/http, which is why a zero status is logged as 200.
Timeouts and body limits in Go middleware
Server timeouts protect connections, and middleware protects individual requests.
On the server, always set ReadHeaderTimeout. Left at zero, it falls back to ReadTimeout, and when that is also zero there is no limit at all. A client can then open connections and send headers one byte at a time for as long as it likes. gosec reports a missing value as G112, a potential Slowloris attack (gosec rules). ReadTimeout, WriteTimeout and IdleTimeout cap the rest of the connection's life, as in the newServer function above.
For a request's own budget, attach a deadline to its context:
example.gogofunc withDeadline(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() next.ServeHTTP(w, r.WithContext(ctx)) }) } }
This only works if the handler passes r.Context() to the database, HTTP clients and anything else that can block. The deadline cancels the work, and the handler decides what to write.
http.TimeoutHandler is the standard library's other option. It writes a 503 when the handler overruns and makes the handler's later writes fail with http.ErrHandlerTimeout. It also buffers the whole response and, according to its docs, "does not support the Hijacker or Flusher interfaces" (net/http). The handler's goroutine keeps running until it returns, so the handler still has to respect its context. Use TimeoutHandler for small JSON endpoints where a guaranteed 503 is worth the buffering. Use the context deadline for anything that streams.
Body limits guard against a client sending a gigabyte to an endpoint that expects a small JSON object:
example.gogofunc limitBody(n int64) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, n) next.ServeHTTP(w, r) }) } }
MaxBytesReader doesn't reject anything up front. A read past the limit returns a *http.MaxBytesError (Go 1.19), and the handler turns it into a 413:
example.gogofunc createPayment(w http.ResponseWriter, r *http.Request) { var req CreatePayment if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if tooBig, ok := errors.AsType[*http.MaxBytesError](err); ok { msg := fmt.Sprintf("body larger than %d bytes", tooBig.Limit) http.Error(w, msg, http.StatusRequestEntityTooLarge) return } http.Error(w, "invalid JSON", http.StatusBadRequest) return } p, _ := PrincipalFrom(r.Context()) slog.InfoContext(r.Context(), "payment accepted", "user_id", p.UserID, "order_id", req.OrderID) w.WriteHeader(http.StatusAccepted) }
errors.AsType arrived in Go 1.26. On older versions, declare var tooBig *http.MaxBytesError and call errors.As(err, &tooBig). Common Go mistakes to avoid covers why errors should be matched by type rather than by message.
How should authentication middleware work?
Auth middleware verifies the caller, stores who they are in the context and passes the request on. It never trusts an identity the client supplied directly:
example.gogotype Principal struct { UserID string Scopes []string } type TokenVerifier interface { Verify(ctx context.Context, token string) (Principal, error) } type principalKey struct{} func PrincipalFrom(ctx context.Context) (Principal, bool) { p, ok := ctx.Value(principalKey{}).(Principal) return p, ok } func requireAuth(verifier TokenVerifier) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || token == "" { w.Header().Set("WWW-Authenticate", "Bearer") http.Error(w, "missing bearer token", http.StatusUnauthorized) return } p, err := verifier.Verify(r.Context(), token) if err != nil { w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) http.Error(w, "invalid token", http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), principalKey{}, p) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func requireScope(scope string) Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p, ok := PrincipalFrom(r.Context()) if !ok { // A wiring bug, not a client error: requireScope ran without requireAuth. http.Error(w, "internal server error", http.StatusInternalServerError) return } if !slices.Contains(p.Scopes, scope) { http.Error(w, "missing scope "+scope, http.StatusForbidden) return } next.ServeHTTP(w, r) }) } }
Three rules are visible in that code. A 401 means "I don't know who you are" and carries a WWW-Authenticate header. A 403 means "I know who you are and the answer is no". Every rejection ends in return, because a middleware that writes an error and then calls next anyway runs the payment handler for an unauthenticated caller.
TokenVerifier is a small interface defined where it is used, so a test can pass a map-backed fake while production passes a JWT or session-store implementation. Headers like X-User-ID from an API gateway are only safe when the gateway is the sole route to your service. If the service can be reached directly, anyone can send that header.
How do you rate limit in Go middleware?
golang.org/x/time/rate provides a token bucket. rate.NewLimiter(10, 20) allows 10 events per second with bursts of up to 20, and Allow reports whether the current event fits (x/time/rate). A per-client limiter keeps one bucket per key and forgets idle clients:
example.gogotype client struct { limiter *rate.Limiter lastSeen time.Time } type RateLimiter struct { mu sync.Mutex clients map[string]*client limit rate.Limit burst int } func NewRateLimiter(limit rate.Limit, burst int) *RateLimiter { return &RateLimiter{clients: make(map[string]*client), limit: limit, burst: burst} } func (rl *RateLimiter) allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() c, ok := rl.clients[key] if !ok { c = &client{limiter: rate.NewLimiter(rl.limit, rl.burst)} rl.clients[key] = c } c.lastSeen = time.Now() return c.limiter.Allow() } // Cleanup drops clients idle for longer than maxIdle until ctx is done. func (rl *RateLimiter) Cleanup(ctx context.Context, maxIdle time.Duration) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: rl.mu.Lock() for key, c := range rl.clients { if time.Since(c.lastSeen) > maxIdle { delete(rl.clients, key) } } rl.mu.Unlock() } } } func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { ip = r.RemoteAddr } if !rl.allow(ip) { w.Header().Set("Retry-After", "1") http.Error(w, "too many requests", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) }
NewRateLimiter has to create the map, because the first allow would otherwise panic on a nil map. Without Cleanup, the map gains an entry for every address that ever connects and never shrinks. Start it once with go limiter.Cleanup(ctx, 10*time.Minute) and cancel ctx on shutdown.
Choosing the key is harder. Behind a load balancer, r.RemoteAddr is the balancer's address, so every client shares one bucket. The obvious fix is the first address in X-Forwarded-For, but the client controls that one. MDN states that if the server can be reached directly from the internet, "no part of the X-Forwarded-For IP list can be considered trustworthy or safe for security-related uses" (MDN). Take the address your own proxy appended, counting from the right by the number of trusted proxies in front of you. Make sure the service can't be reached around them.
This limiter also lives in one process. With four replicas, a client gets four times the limit. For a hard global limit, enforce it at the gateway or in a shared store such as Redis.
How do you protect Go handlers against CSRF?
Go 1.25 added http.CrossOriginProtection. It rejects non-safe cross-origin browser requests by checking the Sec-Fetch-Site header, or by comparing the Origin header's host with Host when that header is missing. GET, HEAD and OPTIONS always pass. It needs no tokens, so it works without template changes. It matters for any route that authenticates with cookies. Browsers never attach a bearer token by themselves, so a pure token API is not exposed to CSRF.
newServer above shows the whole setup: http.NewCrossOriginProtection(), AddTrustedOrigin for a separate admin front end, and csrf.Handler in the global chain. Requests with neither header are treated as same-origin or non-browser traffic and allowed, which is what you want for server-to-server calls.
It is not CORS. CORS headers decide which other origins may read your responses, and CrossOriginProtection doesn't set any. If a single-page app on another domain calls your API, you still need a small CORS middleware with an explicit origin allowlist. It belongs near the top of the chain, because browsers send preflight OPTIONS requests without credentials. Add the same origin with AddTrustedOrigin too. A front end on app.example.com calling api.example.com sends Sec-Fetch-Site: same-site, and CrossOriginProtection rejects its writes even when CORS allows them.
Testing Go middleware with httptest
Test each middleware alone with httptest, using a tiny handler as next to record whether it ran. staticTokens is a map-backed TokenVerifier fake:
example.gogofunc TestRequireAuthRejectsMissingToken(t *testing.T) { called := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) h := requireAuth(staticTokens{})(next) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/payments", nil)) res := rec.Result() if res.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", res.StatusCode) } if got := res.Header.Get("WWW-Authenticate"); got != "Bearer" { t.Errorf("WWW-Authenticate = %q, want Bearer", got) } if called { t.Error("next handler ran for an unauthenticated request") } }
Assert on rec.Result(), not on rec.Header(). Header() returns the live map that handlers mutate, and the docs point you to Result "to test the headers that were written after a handler completes" (httptest). Result().Header is a snapshot taken at the first write, which matches what a real client receives. This middleware has a bug that only Result exposes:
example.gogo// serverTiming sets a header after the handler has written, which is a bug. func serverTiming(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) w.Header().Set("Server-Timing", fmt.Sprintf("app;dur=%d", time.Since(start).Milliseconds())) }) }
rec.Header().Get("Server-Timing") returns a value, so a test on it passes. rec.Result().Header.Get("Server-Timing") returns an empty string, because the handler had already written the response and a real client would never see the header.
Three more tests cover the fixes in this stack. For the Unwrap method, run a handler that calls http.NewResponseController(w).Flush() through logRequests and check rec.Flushed. For both recovery cases, wrap a handler that panics with http.ErrAbortHandler, and one that writes a partial body before panicking, then recover in the test and check that the value is http.ErrAbortHandler. For the slog wrapper, call .With(...) on the logger and confirm the request ID still appears. When you write these, delete the line each test protects and check that the test fails. If it still passes, it isn't testing that fix.
Common Go middleware mistakes
| Mistake | What goes wrong | Fix |
|---|---|---|
| Recovery outside logging | Panics are logged as 200 with no request ID | Order: request ID, logging, recovery |
Wrapper without Unwrap | Flush and SetWriteDeadline fail with ErrNotSupported | Add Unwrap() http.ResponseWriter |
Swallowing http.ErrAbortHandler | Proxy aborts become stack traces and broken 500s | Re-panic it |
| Writing a 500 after the status was sent | A truncated body arrives as a 200 | Abort with http.ErrAbortHandler |
| String context keys | Collisions between packages, staticcheck SA1029 | Unexported struct key and a typed accessor |
Setting headers after next.ServeHTTP | The header is silently dropped | Set headers before calling next |
No return after an error response | The protected handler still runs | Return right after writing the error |
No ReadHeaderTimeout | Slow clients hold connections open, gosec G112 | Set it on every http.Server |
Leftmost X-Forwarded-For as the rate limit key | Clients choose their own bucket | Use the address your proxy appended |
| Rate limiter map with no cleanup | Memory grows with every client address | Evict idle entries on a ticker |
The principle behind the order
The ordering rule applies well beyond this stack. A layer can only observe what happens inside it, so put the layers that record outcomes outside the layers that change them. In an HTTP server, access logs, metrics and trace spans go outside recovery, auth and limits, so they record what the client actually got. In gRPC, a logging interceptor belongs outside the interceptor that converts panics to status codes. A metrics wrapper around a database transaction should wrap the retry loop, or it will count one call when the database saw three.
Where LevelUpGo fits
LevelUpGo teaches these patterns with exercises that run real Go code in the browser. HTTP & Networking has you write logging, chaining and authentication middleware against net/http. Real-World Patterns covers the middleware pattern, panic recovery and chains. Logging with slog goes deeper into the production logging that sits behind your access log middleware.
FAQ
What is middleware in Go?
Middleware in Go is a function with the signature func(http.Handler) http.Handler. It wraps a handler to run code before or after it, for logging, authentication, recovery or limits. Because it takes and returns the standard http.Handler interface, middleware from any package composes with any router that accepts http.Handler, including http.ServeMux.
Do I need a framework or router like chi for middleware in Go?
No. Since Go 1.22, http.ServeMux supports method matching and path wildcards like /orders/{id}, and middleware is just a function that wraps a handler. A ten-line Chain helper covers global and per-route stacks. Routers like chi add conveniences such as route groups, and their middleware uses the same signature, so you can switch later without rewriting it.
What order should Go middleware run in?
Run request ID first, then access logging, then panic recovery, then cheap rejections like cross-origin checks, rate limits and body limits. Put auth and per-route deadlines last. Logging has to sit outside recovery, or panics get logged with a 200 status and no request ID.
How do I pass values from middleware to handlers in Go?
Use context.WithValue with an unexported key type such as type principalKey struct{}, and give handlers a typed accessor like PrincipalFrom(ctx) (Principal, bool). Never use a string as a key, because two packages that pick the same string overwrite each other's values.
How do you test HTTP middleware in Go?
Wrap a stub handler with the middleware, call ServeHTTP with an httptest.NewRecorder() and an httptest.NewRequest, then assert on rec.Result(). Check the status, the headers the client would receive and whether the stub ran. For recovery middleware, recover in the test and compare the value against http.ErrAbortHandler.
Sources
- net/http package documentation (Handler, ErrAbortHandler, ResponseController, TimeoutHandler, MaxBytesReader, CrossOriginProtection, Server)
- net/http/httptest: ResponseRecorder
- context: WithValue
- log/slog package documentation
- golang.org/x/time/rate
- Go 1.19 release notes (MaxBytesError)
- Go 1.20 release notes (ResponseController)
- Go 1.21 release notes (log/slog)
- Go 1.22 release notes (ServeMux patterns)
- Go 1.24 release notes (crypto/rand.Text)
- Go 1.25 release notes (CrossOriginProtection)
- Go 1.26 release notes (errors.AsType)
- MDN: X-Forwarded-For
- gosec rules: G112, ReadHeaderTimeout not configured
