The bouncer pattern
You are building a Go API. You have a /users endpoint that lists public profiles. It works. You add /admin/delete-all. You realize anyone with the URL can wipe the database. You add a check for an API key inside the handler. It works. You add /admin/export-data. You copy the check. You add /admin/update-settings. You copy the check again.
Now you have three places where the key logic lives. You change the key format, and you have to update three files. You forget one. The bug ships. You also notice that every handler now has boilerplate code that has nothing to do with the business logic. The handler should handle the request, not police the door.
Middleware solves this. Middleware is a wrapper that intercepts the request before it reaches the handler. It checks the credentials. If they are valid, it passes the request along. If not, it stops the request and sends an error response. The handler never sees invalid requests. You write the check once, and apply it wherever you need protection.
How middleware works
Go's net/http package is built around a single interface: http.Handler. Every request flows through this interface.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
A handler receives a ResponseWriter to send the response and a *Request containing the incoming data. Middleware is just a function that takes a http.Handler and returns a new http.Handler. The returned handler wraps the original one. It can run code before calling the original, run code after, or decide not to call it at all.
Go provides http.HandlerFunc to make this easy. It is a type that adapts a function with the signature func(http.ResponseWriter, *http.Request) into a http.Handler. This avoids writing boilerplate structs just to implement ServeHTTP.
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
When you return http.HandlerFunc(...) from your middleware, you are creating a handler that runs your anonymous function. Inside that function, you call next.ServeHTTP(w, r) to pass control to the wrapped handler. The chain continues until the final handler writes the response.
Middleware is a gatekeeper. If the gate stays shut, the handler never runs.
Minimal implementation
Here is the simplest authentication middleware. It checks the Authorization header. If the token is missing or invalid, it returns a 401 status. If valid, it calls the next handler.
// AuthMiddleware wraps a handler to check for a valid token in the Authorization header.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract the token from the request header.
token := r.Header.Get("Authorization")
// Validate the token. Return 401 if missing or invalid.
if token == "" || !isValidToken(token) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Token is valid. Pass control to the next handler in the chain.
next.ServeHTTP(w, r)
})
}
// isValidToken checks the token against a secret.
func isValidToken(token string) bool {
return token == "super-secret-key"
}
The http.Error function writes the status code and body in one call. The return statement after http.Error is crucial. It stops execution. If you omit the return, the code falls through to next.ServeHTTP, and the request proceeds despite the error. The compiler will not catch this. You just get a 401 body followed by the success response, which confuses clients.
Walking the chain
When a request arrives, the middleware function executes. It reads the header. If the check fails, http.Error writes to the ResponseWriter. The function returns. The response is sent to the client. The wrapped handler is never called.
If the check passes, next.ServeHTTP(w, r) runs. This invokes the wrapped handler. That handler writes its response. Control returns to the middleware function, which then returns. The chain is complete.
You can stack middleware. AuthMiddleware(LogMiddleware(handler)) creates a chain where logging runs first, then auth, then the handler. The order matters. If you wrap auth inside logging, the log records every request, including rejected ones. If you wrap logging inside auth, the log only records requests that pass auth.
Convention aside: Go functions that take a context should always have ctx as the first parameter. Middleware often needs to respect context cancellation. If the client disconnects while the middleware is doing a slow validation, the context will be cancelled. Check r.Context().Done() if your validation takes time.
Real-world: context and identity
Real middleware does more than reject requests. It often extracts user information and makes it available to the handler. Passing arguments through every layer is tedious. Go uses context.Context to carry request-scoped values. Middleware can store the user ID in the context, and handlers can retrieve it.
Here is middleware that validates a token and injects the user ID into the context.
// AuthMiddleware validates the token and stores the user ID in the request context.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get the token from the header.
token := r.Header.Get("Authorization")
// Parse and validate the token.
userID, err := parseToken(token)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Store the user ID in the context for downstream handlers.
ctx := context.WithValue(r.Context(), userIDKey{}, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// userIDKey is a custom type for the context key to avoid collisions.
type userIDKey struct{}
// parseToken validates the token and returns the user ID.
func parseToken(token string) (string, error) {
if token == "valid-token" {
return "user-123", nil
}
return "", fmt.Errorf("invalid token")
}
The context.WithValue function returns a new context with the value attached. The userIDKey{} struct is the key. Using a custom type for the key prevents collisions. If you use a string key like "userID", another package might use the same string, and you'll get the wrong value. The compiler won't stop you. A struct key is type-safe.
The r.WithContext method returns a shallow copy of the request with the new context. Requests are not safe for concurrent use, but WithContext is the standard way to propagate context changes. Handlers retrieve the value using r.Context().Value(userIDKey{}).
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. In middleware, you often see if err != nil { http.Error(...); return }. Keep the error handling explicit.
Context is the backpack. Pack the user, pass the request.
Pitfalls and errors
Middleware has a few common traps.
Forgetting to call next is the most frequent mistake. If the middleware validates the token but omits next.ServeHTTP, the request hangs. The client waits for a response that never comes. The server logs show no error. The timeout kills the connection. Always ensure every path either writes a response or calls next.
Modifying the request directly can cause race conditions. The *Request object is shared. If you mutate headers or URL, you might affect other goroutines. Use r.WithContext for context changes. For other modifications, clone the request or use the provided methods.
Using string keys for context invites collisions. The compiler rejects cannot use "userID" (untyped string constant) as userIDKey value in argument if you try to use a string where a struct key is expected, but if you define your own retrieval function that accepts any, you can accidentally pass the wrong key. Stick to struct keys.
Order errors are subtle. If you have a rate-limiting middleware and an auth middleware, the order determines behavior. Rate-limiting before auth protects the auth service from brute force. Auth before rate-limiting means unauthenticated requests consume no rate limit budget. Think about the chain.
Convention aside: Public names start with a capital letter. Private names start lowercase. AuthMiddleware is exported so other packages can use it. isValidToken is private. If you need to share validation logic, export it or provide a constructor.
The worst middleware bug is the one that silently drops the request. Always log or return an error.
Testing middleware
Testing middleware requires httptest. You create a mock handler, wrap it with the middleware, and send a test request. You check the response status and body.
// TestAuthMiddleware verifies the middleware rejects invalid tokens and passes valid ones.
func TestAuthMiddleware(t *testing.T) {
// Create a dummy handler that always returns 200.
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Wrap the handler with the middleware.
handler := AuthMiddleware(next)
// Test case: invalid token.
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "bad-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
// Test case: valid token.
req2 := httptest.NewRequest(http.MethodGet, "/protected", nil)
req2.Header.Set("Authorization", "valid-token")
rr2 := httptest.NewRecorder()
handler.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr2.Code)
}
}
The httptest.NewRecorder captures the response. You can check rr.Code, rr.Body, and headers. The test covers both paths: rejection and acceptance. This ensures the middleware behaves correctly without running a full server.
Test the wrapper, not just the wrapped.
When to use middleware
Middleware is a powerful pattern, but it is not always the right tool. Use the right structure for the job.
Use middleware when you need to apply a cross-cutting concern like authentication, logging, or rate limiting to multiple handlers. Use a direct check inside the handler when the logic is specific to a single endpoint and unlikely to be reused. Use a router group wrapper when you want to protect a subtree of routes without touching individual handlers. Use an external library when you need complex flows like OAuth2, JWT rotation, or session cookies.
Middleware is plumbing. Run it through every long-lived call site.