Back to Blog

Go (Golang) Middleware Best Practices for Production

Go middleware best practices for production: the order that stops panics logging as 200, ResponseWriter wrappers with Unwrap, request IDs, timeouts and auth.

Go (Golang) Middleware Best Practices for Production

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.Handler and 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 ResponseWriter wrapper needs an Unwrap() http.ResponseWriter method. Without it, http.ResponseController can'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 ReadHeaderTimeout on the server, give each route a context deadline, and cap bodies with http.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-For value.

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.gogo
type 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.gogo
func 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:

#MiddlewareWhy it sits here
1Request IDEvery later layer, including the log line and the panic report, can read the ID.
2Access loggingIt wraps recovery, so it records the status that recovery wrote.
3Panic recoveryIt catches panics from every inner layer and the handler and turns them into a 500.
4Cross-origin protectionIt rejects cross-site browser writes before they cost any work.
5Rate limit per client IPIt sheds abusive traffic before auth pays for a token lookup.
6Body limitIt caps every request body before any handler reads it.
7Auth and scopes, per routeOnly routes that need a caller pay for verifying one.
8Deadline, per routeEach 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.gogo
type 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.gogo
func 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.gogo
func 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.gogo
type 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.gogo
type 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.gogo
func 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.gogo
func 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.gogo
func 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.gogo
func 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.gogo
type 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.gogo
type 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.gogo
func 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

MistakeWhat goes wrongFix
Recovery outside loggingPanics are logged as 200 with no request IDOrder: request ID, logging, recovery
Wrapper without UnwrapFlush and SetWriteDeadline fail with ErrNotSupportedAdd Unwrap() http.ResponseWriter
Swallowing http.ErrAbortHandlerProxy aborts become stack traces and broken 500sRe-panic it
Writing a 500 after the status was sentA truncated body arrives as a 200Abort with http.ErrAbortHandler
String context keysCollisions between packages, staticcheck SA1029Unexported struct key and a typed accessor
Setting headers after next.ServeHTTPThe header is silently droppedSet headers before calling next
No return after an error responseThe protected handler still runsReturn right after writing the error
No ReadHeaderTimeoutSlow clients hold connections open, gosec G112Set it on every http.Server
Leftmost X-Forwarded-For as the rate limit keyClients choose their own bucketUse the address your proxy appended
Rate limiter map with no cleanupMemory grows with every client addressEvict 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

Write Go like a senior engineer

Interactive lessons in your browser. The first ones are free.

Try a free lessonOr create a free account