Go HTTP middleware explained: what it is, how it works, and how to build your own

This is part 2 of 2 in the series Go HTTP middleware from scratch. If you're new to Go types, → read part 1 first.
The same code keeps showing up everywhere
You're building an HTTP server. Every request needs a unique ID for tracing. Every request needs to check authentication. Every request needs its duration logged. The naive approach: copy that code into every handler.
// Without middleware — same boilerplate in every single handler
func getUserHandler(w http.ResponseWriter, r *http.Request) {
// Auth check — copy/pasted from every other handler
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Request ID — copy/pasted from every other handler
requestID := uuid.New().String()
log.Printf("requestID=%s handling getUser", requestID)
// actual handler logic finally starts here...
}
Do this across 20 handlers, and you have a maintenance nightmare. Change the auth logic once, and you have to update 20 files.
Middleware is the Go way to solve this. Write the cross-cutting logic once, wrap it around your handlers, and never think about it again.
What is this post about
What middleware is and how Go's HTTP model makes it natural.
How the http.Handler interface works, the foundation everything builds on.
How to write your own middleware from scratch.
How to chain multiple middleware together.
How to pass data through the chain using context.
Tips for advanced usage: ordering, third-party routers, and common pitfalls.
The foundation: the http.Handler interface
As I mentioned in the first part of this serie, everything in Go's HTTP stack is built on one tiny interface:
// This is the entire http.Handler interface — just one method.
// Any type that has a ServeHTTP method satisfies it automatically.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
If you've read the previous posts in this series, you'll recognize this pattern. Go interfaces are just method signatures. Any Go type with ServeHTTP(ResponseWriter, *Request) is automatically an HTTP handler. No registration, no declaration. If you remember, http.HandlerFunc is a convenience type that lets you turn a plain function into an http.Handler:
// HandlerFunc is a function type that also implements http.Handler.
// This is how you write handlers without creating a struct.
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
This means these two ways of writing a handler are equivalent:
// Option A: using http.HandleFunc, to convert a plain function
// to a HandlerFunc type
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
})
// Option B: a struct implementing http.Handler
type HelloHandler struct{}
func (h HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
}
Most people use option A for simple handlers. The struct approach becomes useful when your handler needs to hold state (like a database client).
What is middleware?
Middleware is a function that:
Accepts an http.Handler.
Returns a new http.Handler.
Does something before and/or after calling the original handler.
// This is the shape of every middleware in Go.
// It wraps a handler and returns a new one.
func MyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Do something BEFORE the handler runs
// (auth check, logging, adding context...)
next.ServeHTTP(w, r) // call the actual handler
// Do something AFTER the handler runs
// (log the response, record timing...)
})
}
The next parameter is the handler this middleware wraps. Calling next.ServeHTTP(w, r) passes control forward, like handing a baton in a relay race.
The relay race explained
Here's the mental model that makes middleware click:
Request comes in
│
▼
┌─────────────────┐
│ Middleware 1 ← does its work (e.g. logs start time)
│ │
│ ▼
│ Middleware 2 ← does its work (e.g. checks auth)
│ │
│ ▼
│ Handler ← does the actual business logic
│ │
│ ▼
│ Middleware 2 ← does its work after (e.g. checks result)
│ │
│ ▼
│ Middleware 1 ← does its work after (e.g. logs duration)
└─────────────────┘
│
▼
Response goes out
Each middleware wraps the next layer. Code before next.ServeHTTP runs on the way in. Code after runs on the way out. This is sometimes called the "onion model."
Building three real middleware functions
Let's build three practical middleware functions and chain them together. You can find the repo here.
But before writing any code, here's how the project is laid out. You will get a clearer understanding of how a Golang project should be structured if you read this post first about Go Packages. Each middleware gets its own file inside a shared middleware package, and cmd/server/main.go wires everything together.
go-middleware-examples/
├── go.mod
├── cmd/
│ └── server/
│ └── main.go ← entry point — registers routes and chains middleware
└── internal/
└── middleware/
├── requestid.go ← RequestID middleware + context key + getter
├── logger.go ← Logger middleware + responseWriter wrapper
├── auth.go ← Auth middleware
└── chain.go ← Chain helper for readable middleware composition
A few decisions worth noting:
All middleware lives under
internal/middleware/, it's private to this module. If you later wanted to publish this middleware as a reusable library for other projects, it would move topkg/middleware/instead.Each middleware gets its own file even though they all declare
package middleware. This keeps each one focused and easy to find,requestid.goonly ever deals with request IDs,auth.goonly ever deals with authentication.The context key (
RequestIDKey) and its getter (GetRequestID) live in the same file as the middleware that sets it (requestid.go). Anything that reads the request ID imports themiddlewarepackage and callsmiddleware.GetRequestID(ctx), never reaching into the context directly. You can know more about Go contexts here.cmd/server/main.gostays thin, it only registers handlers and chains middleware together. No middleware logic lives there.
From the root directory (go-middleware-examples), run the following commands to create your Go project, and install the uuid package:
go mod init github.com/<your-username>/go-middleware-examples
go get github.com/google/uuid
You can name your project as you want. I usually name my projects according to my GitHub app repo name.
Here's the go.mod for reference:
module github.com/FerRiosCosta/go-middleware-examples
go 1.26.3
require github.com/google/uuid v1.6.0 // indirect
Now let's build each file.
Request ID middleware
Please consider the first comment line, which specifies where the file should be created. This middleware attaches a unique ID to every request. Useful for tracing a single request through your logs.
// internal/middleware/requestid.go
package middleware
import (
"context"
"net/http"
"github.com/google/uuid"
)
// key is an unexported type for context keys in this package.
// Using a custom type prevents collisions with keys from other packages.
// (Two packages both using the string "requestID" would overwrite each other.)
type key string
// RequestIDKey is the context key for the request ID.
// Exported so handlers in other packages can retrieve it.
const RequestIDKey key = "requestID"
// RequestID generates a unique ID for every incoming request,
// stores it in the context, and adds it to the response headers.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate a new UUID for this request.
id := uuid.New().String()
// Store the ID in a new context derived from the request's context.
// r.Context() returns the existing context, we build on top of it,
// not replace it, so any previously set values are preserved.
ctx := context.WithValue(r.Context(), RequestIDKey, id)
// Add the ID to the response header.
// This lets clients (and load balancers) correlate their logs with yours.
w.Header().Set("X-Request-ID", id)
// This is the line from the previous explanation:
// create a copy of the request with the new context attached,
// then pass it to the next handler in the chain.
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetRequestID is a helper to retrieve the request ID from a context.
// Handlers call this instead of reaching into the context directly.
func GetRequestID(ctx context.Context) string {
id, _ := ctx.Value(RequestIDKey).(string)
return id
}
Logger middleware
Logs every request: method, path, duration, and status code. But, before looking at the full code, let's first try to understand how this logger middleware works under the hood.
What is http.ResponseWriter?
Before looking at the code, it helps to understand the type we're about to wrap.
http.ResponseWriter is an interface, every handler you write receives one as its first argument. It's how your handler sends a response back to the client: the status code, the headers, and the body.
// http.ResponseWriter is an interface with three methods:
type ResponseWriter interface {
Header() http.Header // get/set response headers
Write([]byte) (int, error) // write the response body
WriteHeader(statusCode int) // write the status code (200, 404, 500...)
}
A typical handler uses it like this:
func myHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) // write the status code
w.Write([]byte("hello!")) // write the body
}
The problem: you can't read the status code back
Here's the catch. http.ResponseWriter lets you write a status code, but it has no method to read it back afterward. There's no w.StatusCode() you can call. Once you write it, it's gone, sent straight to the client over the network.
This is a problem for our Logger middleware. Remember the relay race model: Logger calls next.ServeHTTP(w, r), waits for the handler to finish, and only then logs the result. But by the time the handler finishes, it has already called w.WriteHeader(404) (or whatever status it chose), and Logger has no way to find out what that status was. The information is gone.
The solution: wrap it in our own struct
To solve this, we create our own type that:
Embeds the original
http.ResponseWriter, so it still behaves exactly the same way for the handlerAdds a field to remember the status code
Intercepts the one method we care about (
WriteHeader) to save the value before passing it through.
// responseWriter wraps http.ResponseWriter to capture the status code.
// http.ResponseWriter doesn't expose the status code after it's written,
// so we intercept WriteHeader to save it.
type responseWriter struct {
http.ResponseWriter // embed the original, we get all its methods for free
statusCode int // the status code the handler wrote
}
The embedded http.ResponseWriter is the key part. In Go, embedding a type inside a struct means the outer struct automatically gets all of the embedded type's methods, for free, with no extra code. So our responseWriter already has Header(), Write(), and WriteHeader() just by embedding http.ResponseWriter. As far as the handler is concerned, our responseWriter looks and behaves exactly like a normal http.ResponseWriter, because it satisfies the same interface.
The only thing left to do is override WriteHeader, the one method we actually need to intercept:
// WriteHeader intercepts the status code before passing it through.
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code // save it for logging
rw.ResponseWriter.WriteHeader(code) // pass it through to the real writer
}
This is the trick: when our responseWriter defines its own WriteHeader method, that one overrides the embedded version. So when a handler calls w.WriteHeader(404), it actually calls our version, which saves 404 into rw.statusCode, then calls the original ResponseWriter.WriteHeader(404) so the client still receives the real response. The handler never notices anything different happened.
Now Logger can read wrapped.statusCode after the handler finishes, something that was impossible with a plain http.ResponseWriter.
Here's the full middleware:
// internal/middleware/logger.go
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter wraps http.ResponseWriter to capture the status code.
// http.ResponseWriter doesn't expose the status code after it's written,
// so we intercept WriteHeader to save it.
type responseWriter struct {
http.ResponseWriter // embed the original — we get all its methods for free
statusCode int // the status code the handler wrote
}
// WriteHeader intercepts the status code before passing it through.
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code // save it for logging
rw.ResponseWriter.WriteHeader(code) // pass it through to the real writer
}
// Logger logs each request's method, path, status code, duration,
// and request ID (if set by the RequestID middleware).
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Record when the request arrived.
start := time.Now()
// Wrap the ResponseWriter so we can capture the status code.
wrapped := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK, // default to 200 if WriteHeader is never called
}
// Call the next handler — this is where the actual work happens.
next.ServeHTTP(wrapped, r)
// By the time we reach here, the handler has finished.
// We can now log everything we know about this request.
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration", time.Since(start).String(),
"requestID", GetRequestID(r.Context()), // set by RequestID middleware
)
})
}
Auth middleware
Checks for a valid Bearer token before allowing the request through.
This one looks more complicated than RequestID and Logger at first glance, it's a function that returns a function that returns a function. Let's slow down and unpack why, before looking at the full code.
Why Auth needs an extra layer
RequestID and Logger both have this shape:
func RequestID(next http.Handler) http.Handler {
// ...
}
One input (next), one output (http.Handler). That's the standard middleware shape we covered earlier.
But Auth needs something extra: a configurable token. Every other middleware works the same way no matter what, but Auth needs to know which token counts as valid, and that's different for every app (and usually comes from an environment variable, not a hardcoded value).
So Auth can't just be a middleware, it needs to be a function that produces a middleware, once you tell it which token to check:
// Auth is NOT middleware itself.
// It's a function that BUILDS a middleware, once you give it a token.
func Auth(validToken string) func(http.Handler) http.Handler {
// ...
}
Breaking down the three layers
Here's the same function with each layer labeled:
// ┌─── Layer 1: takes your config (the token)
// │
func Auth(validToken string) func(http.Handler) http.Handler {
// └─── Layer 2: this is what gets returned —
// a real middleware function, with the
// standard func(http.Handler) http.Handler shape
return func(next http.Handler) http.Handler {
// └─── Layer 2 starts here. This is now a normal middleware,
// identical in shape to RequestID and Logger.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// └─── Layer 3: the actual handler logic that runs per-request.
// This is the same as the inner function in every middleware
// you've already seen.
// ... auth check logic goes here ...
})
}
}
Three layers, three different jobs:
| Layer | Runs when | Job |
|---|---|---|
1 — Auth(validToken) |
Once, at startup | Receives your config (the token) |
2 — func(next http.Handler) http.Handler |
Once, when you build your middleware chain | Receives the next handler to wrap |
3 — func(w, r) |
On every single request | Does the actual work — checks the header |
Why this pattern, and how you use it
The payoff is that calling Auth looks almost identical to using RequestID or Logger, you just add one extra set of parentheses to pass in the token:
// RequestID and Logger take no config, pass them directly
middleware.RequestID
middleware.Logger
// Auth takes config, call it first to get back a real middleware
middleware.Auth("my-secret-token") // ← this call returns a middleware function
That's it. Auth("my-secret-token") runs Layer 1 immediately and hands you back Layer 2, a normal func(http.Handler) http.Handler, ready to be used exactly like any other middleware in your Chain:
Chain(mux,
middleware.RequestID, // sets the request ID first
middleware.Logger, // then logs — sees the ID
middleware.Auth("my-secret-token"), // called with () — returns a middleware
)
If this still feels unfamiliar, it helps to compare it to something simpler, a function that builds a greeting function:
// Same shape, much simpler example.
// MakeGreeter takes a name and returns a NEW function
// that already "remembers" that name.
func MakeGreeter(name string) func() string {
return func() string {
return "Hello, " + name + "!"
}
}
sayHiToAlice := MakeGreeter("Alice") // returns a function
fmt.Println(sayHiToAlice()) // "Hello, Alice!"
Auth does the exact same thing, it just returns a middleware function instead of a greeting function. Once you see MakeGreeter, Auth(validToken) stops looking complicated; it's the same "function that builds and returns another function" pattern, just doing more useful work inside.
Now here's the full code with that context in mind:
// internal/middleware/auth.go
package middleware
import (
"net/http"
"strings"
)
// Auth checks for a valid Bearer token in the Authorization header.
// If the token is missing or invalid, it returns 401 and stops the chain —
// next.ServeHTTP is never called, so the handler never runs.
func Auth(validToken string) func(http.Handler) http.Handler {
// Return a middleware function — this pattern lets us configure
// middleware with parameters (the token) while keeping the standard shape.
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read the Authorization header.
authHeader := r.Header.Get("Authorization")
// Check the format: "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
// Respond with 401 and stop. next.ServeHTTP is never called.
http.Error(w, "missing or malformed token", http.StatusUnauthorized)
return
}
// Validate the token.
// In a real app you'd verify a JWT or look up the token in a database.
if parts[1] != validToken {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Token is valid — pass the request to the next handler.
next.ServeHTTP(w, r)
})
}
}
Chaining middleware together
Now let's wire everything up. But first, create your own secret token and paste it into your main.go file, you can create it from here. Remember to also include that token in your request. You can see how to do it in the section about testing the server.
The standard Go way to chain middleware is to nest the calls:
// cmd/server/main.go
package main
import (
"fmt"
"net/http"
"github.com/yourname/myapp/internal/middleware"
)
// userHandler is the actual business logic.
// It doesn't know about auth, logging, or request IDs —
// all of that is handled by middleware before it runs.
func userHandler(w http.ResponseWriter, r *http.Request) {
// Retrieve the request ID that was set by RequestID middleware.
id := middleware.GetRequestID(r.Context())
fmt.Fprintf(w, "hello! your request ID is %s\n", id)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/user", userHandler)
// Chain middleware by wrapping from the outside in.
// The outermost middleware runs first on the way IN,
// and last on the way OUT.
//
// RequestID must wrap Logger — not the other way around.
// Logger only ever sees the *r it directly receives. If RequestID
// runs AFTER Logger, Logger is holding the original request,
// not the enriched one RequestID produces — so the ID would be empty.
//
// Execution order for a request:
// RequestID → Logger → Auth → userHandler
// (then back out in reverse)
handler := middleware.RequestID(
middleware.Logger(
middleware.Auth("your-secret-token")(
mux,
),
),
)
http.ListenAndServe(":8080", handler)
}
Testing the server
# Start the server
go run ./cmd/server
# Valid request — should return 200 with a request ID
curl -H "Authorization: Bearer your-secret-token" http://localhost:8080/user
# Missing token — should return 401
curl http://localhost:8080/user
# Invalid token — should return 401
curl -H "Authorization: Bearer wrong-token" http://localhost:8080/user
Server logs:
2026/06/21 20:46:13 INFO request method=GET path=/user status=200 duration=7.208µs requestID=e667de2b-310a-4038-9891-2d948613e93b
2026/06/21 20:46:15 INFO request method=GET path=/user status=200 duration=90.667µs requestID=23a61730-4dd2-4b01-8dc5-986fbcb46436
Every request gets logged. The request ID ties your server log to the X-Request-ID header in the response, if a client reports a bug, they can give you their request ID and you can find exactly that request in your logs.
Best practices
Order matters, RequestID before Logger, Logger before Auth
The outermost middleware runs first on the way in and last on the way out. Two rules drive the order here:
RequestIDmust be outermost (or at least beforeLogger),Loggercan only read context values from the exact*http.Requestit was handed.context.WithValueandr.WithContextdon't mutate the original request, they return a new one. IfRequestIDruns afterLogger,Loggeris stuck holding the original, un-enriched request,GetRequestIDwill always come back empty. If you are new to Go contexts, you can check this post about it; it explains everything you need to know about Go contexts.Loggershould wrapAuth, so that failed (401) requests still get logged, not just successful ones.
// Good order
Chain(mux,
middleware.RequestID, // outermost: enriches the request first
middleware.Logger, // sees the enriched request — requestID shows up in logs
middleware.Auth(...), // innermost: blocks invalid requests, but they're still logged
)
// Bad order — this is the bug you'll hit if you swap these two
Chain(mux,
middleware.Logger, // ← runs first, but hasn't seen RequestID's context yet
middleware.RequestID, // ← enriches the request, but only for handlers AFTER this point
middleware.Auth(...),
)
// Logger's slog.Info call reads r.Context() from its OWN copy of r —
// which was captured before RequestID ever ran. requestID="" every time.
The general rule: any middleware that reads a context value must be positioned after (further in than) the middleware that sets it.
Never call next.ServeHTTP more than once
Calling it twice sends a double response, which causes a panic in Go's HTTP stack. Always return after an early exit:
// Good — returns after writing the error
if !valid {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // ← critical: stops execution here
}
next.ServeHTTP(w, r)
// Bad — falls through and calls next anyway
if !valid {
http.Error(w, "unauthorized", http.StatusUnauthorized)
// missing return — next.ServeHTTP still runs!
}
next.ServeHTTP(w, r)
Use unexported context keys
Always define context keys as a custom unexported type, not a plain string. This prevents key collisions between packages:
// Bad — any package using the string "userID" will collide
ctx = context.WithValue(ctx, "userID", id)
// Good — only this package can access this key
type key string
const userIDKey key = "userID"
ctx = context.WithValue(ctx, userIDKey, id)
Tips for advanced users
Tip 1: Use a third-party router for cleaner middleware
The standard library's http.ServeMux applies middleware to all routes at once. If you need per-route middleware (auth on some routes, not others), a router like chi or gorilla/mux makes this clean:
r := chi.NewRouter()
r.Use(middleware.Logger) // applies to all routes
r.Use(middleware.RequestID) // applies to all routes
r.Group(func(r chi.Router) {
r.Use(middleware.Auth("secret")) // applies only to routes in this group
r.Get("/admin", adminHandler)
r.Get("/users", usersHandler)
})
r.Get("/health", healthHandler) // no auth required
Tip 2: Capture response body size
Extend responseWriter to also track bytes written — useful for monitoring:
type responseWriter struct {
http.ResponseWriter
statusCode int
bytes int
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytes += n // track how many bytes were written
return n, err
}
Tip 3: Recover from panics
A panic in any handler crashes the goroutine handling that request. A recovery middleware catches it and returns a 500 instead of crashing:
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic recovered", "error", err, "requestID", GetRequestID(r.Context()))
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Add this as the outermost middleware so it catches panics from every layer:
Chain(mux,
middleware.Recovery, // outermost — catches panics from every layer below
middleware.RequestID, // sets the request ID next
middleware.Logger, // sees the enriched request — requestID shows up in logs
middleware.Auth("secret"),
)
Summary
Middleware is just a function that wraps a handler and returns a new one. Everything else follows from that.
Three things to take away:
next.ServeHTTP(w, r.WithContext(ctx)), this is the line that passes the request forward with an enriched context. Understanding it unlocks the whole pattern.Order matters, outermost middleware runs first on the way in, last on the way out. Anything that sets a context value (like
RequestID) must come before anything that reads it (likeLogger), or the value will be empty.Always
returnafter early exits, callingnext.ServeHTTPafter writing an error response causes a double response panic.
The three middleware functions in this post, RequestID, Logger, and Auth, are production-ready. Add Recovery and you have a solid foundation for any Go HTTP server.




