Hello! Welcome back.
In our last lesson, you built robust HTTP handlers that can produce and consume JSON. You learned how to validate incoming data to create production-ready endpoints. This is the core business logic of your API.
However, you probably noticed that some tasks, like logging requests or checking authentication, don't belong inside every single handler. Repeating this "cross-cutting" logic would violate the DRY (Don't Repeat Yourself) principle and make your code hard to maintain.
Today, we will solve that problem by learning how to apply the middleware pattern for centralized logging and authentication. Middleware acts as a series of processing steps that a request must pass through before it reaches your main handler. This powerful pattern allows you to compose functionality in a clean, modular way.

By the end of this lesson, you'll be able to create and apply middleware to:
- Log every incoming HTTP request with its method, path, and processing time.
- Protect specific API routes by requiring an authentication token.
- Chain multiple middleware functions together to build a request-processing pipeline.
This is a fundamental technique for building secure, observable, and maintainable backend services—a cornerstone of professional Go development.
1. Understanding the Middleware Pattern
At its heart, Go middleware is a function that takes one http.Handler and returns another. Its purpose is to perform some action and then, if appropriate, pass the request along to the next handler in the chain.
The standard signature for a middleware function looks like this:
func myMiddleware(next http.Handler) http.Handler {
// This returned function is the actual middleware handler.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ... logic to execute before the next handler ...
// Call the next handler in the chain.
next.ServeHTTP(w, r)
// ... logic to execute after the next handler ...
})
}
Let's break this down:
- The outer function,
myMiddleware, is a factory. It accepts thenexthandler that should be called after it's done. - It returns a new
http.Handler(commonly by wrapping a function literal withhttp.HandlerFunc). - Inside this returned handler, you have full control. You can inspect the request, modify headers, write to the response, and—crucially—decide whether to call
next.ServeHTTP(w, r)to continue the chain or to stop processing right there.
Making and using HTTP Middleware in Go
For a definitive explanation of this structure, let's turn to a classic article on the topic by Alex Edwards, 'Making and using HTTP Middleware in Go'.
Read the section titled 'The standard pattern'. It clearly lays out the function signature and explains how the middleware function wraps another handler to form a closure.
With this pattern in mind, let's apply it to a real-world use case: centralized logging.
2. Implementing a Logging Middleware
A common requirement for any API is to log incoming requests. A logging middleware is perfect for this. It can record the request method, URL path, and how long the request took to process, providing valuable observability into your service.
Your previous experience with Python frameworks like Flask or Django likely involved decorators or middleware classes for this exact purpose. The Go pattern achieves the same result with simple functions.
The standard library now has all you need for advanced routing in Go.
The video 'The standard library now has all you need for advanced routing in Go' provides a fantastic, concise demonstration of building a logging middleware from scratch.
Watch from 06:02 to 09:04. The first part (until 08:03) shows the creation of a basic logging middleware. The second part (from 08:03) demonstrates a clever technique to also log the HTTP status code, which isn't directly available.
Let's put this into practice. Here is a main.go file with a logging middleware that times requests.
package main
import (
"log"
"net/http"
"time"
)
// loggingMiddleware logs the incoming request method, path, and processing time.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Call the next handler in the chain.
next.ServeHTTP(w, r)
// Log the request details after the handler has finished.
log.Printf(
"%s %s %v",
r.Method,
r.URL.Path,
time.Since(start),
)
})
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthCheckHandler)
// Wrap the entire mux with the logging middleware.
// Now, every request handled by mux will be logged.
wrappedMux := loggingMiddleware(mux)
log.Println("Starting server on :8080")
if err := http.ListenAndServe(":8080", wrappedMux); err != nil {
log.Fatalf("Could not start server: %s\n", err)
}
}
Notice how we wrap the mux itself in the middleware: loggingMiddleware(mux). Because http.ServeMux also implements the http.Handler interface, we can apply middleware to it directly, effectively applying it to all routes it manages.
Run this code (go run .) and make a request with curl http://localhost:8080/health. You will see a log line in your terminal like: GET /health 15.375µs.
3. Guarding Routes with Authentication Middleware
The other key use case from our learning outcome is authentication. Middleware is the ideal place to check for credentials, like an API key in a header, before the request reaches the business logic.
An authentication middleware acts as a "guard." If the request is authenticated, it calls the next handler. If not, it stops the chain and immediately sends an error response (e.g., 401 Unauthorized).
Making and using HTTP Middleware in Go
The same Alex Edwards article provides a great example of this 'guard' pattern for authentication.
First, read the short section 'Early returns' to understand the concept of stopping the middleware chain. Then, read 'A more realistic example', focusing on the requireBasicAuthentication middleware. It shows exactly how to implement this guard logic.
Let's build on our previous example. We'll add a "secret" endpoint that requires an X-API-Key header.
First, add this authentication middleware to your main.go:
// A simple, hardcoded API key for demonstration purposes.
const validApiKey = "super-secret-key"
// authMiddleware checks for a valid API key in the X-API-Key header.
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
if apiKey != validApiKey {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
// Return early, not calling the next handler.
return
}
// API key is valid, call the next handler.
next.ServeHTTP(w, r)
})
}
And add a secret handler and a new route to your main function:
func secretDataHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data": "this is top secret"}`))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthCheckHandler)
// Apply authMiddleware only to the secret handler.
mux.Handle("GET /secret", authMiddleware(http.HandlerFunc(secretDataHandler)))
wrappedMux := loggingMiddleware(mux)
log.Println("Starting server on :8080")
if err := http.ListenAndServe(":8080", wrappedMux); err != nil {
log.Fatalf("Could not start server: %s\n", err)
}
}
In this case, we applied the middleware to a specific route: mux.Handle("GET /secret", authMiddleware(...)). This is how you protect individual endpoints.
Run the updated server and try these curl commands:
-
Request without the API key (fails):
curl -i http://localhost:8080/secret # You will get HTTP/1.1 401 UnauthorizedNotice in your server logs that the request was still logged by
loggingMiddleware! This shows how the outer middleware runs even if an inner one rejects the request. -
Request with the API key (succeeds):
curl -i -H "X-API-Key: super-secret-key" http://localhost:8080/secret # You will get HTTP/1.1 200 OK and the secret data.
Test your understanding!
Imagine you have two middleware functions: rateLimitMiddleware and loggingMiddleware. Your goal is to log all incoming requests, but you only want to apply rate limiting logic before the request is logged if the rate limit is exceeded. If the request is allowed, it should then be logged. How would you chain them?
Show answer
You would wrap the logging middleware with the rate-limiting middleware:
rateLimitMiddleware(loggingMiddleware(myHandler))
The flow would be:
rateLimitMiddlewarereceives the request.- If the rate limit is exceeded, it returns a
429 Too Many Requestserror and stops. TheloggingMiddlewareis never called. - If the rate limit is not exceeded, it calls
next.ServeHTTP(), which passes control tologgingMiddleware. loggingMiddlewarelogs the request and then callsmyHandler.
This ensures that only valid, non-rate-limited requests are passed to the logging layer and beyond.
4. Chaining Middleware and Passing Data
What if you want to apply both logging and authentication to the /secret route? You'd nest them: loggingMiddleware(authMiddleware(handler)). As you add more, this nesting becomes cumbersome. Experienced Go developers often create a small utility to chain middleware cleanly.
Furthermore, authentication middleware often does more than just validate a token; it also decodes it to extract information, like a User ID. How do you pass this information down to the final handler? The answer is Go's context package.
A middleware can add values to the request's context, and downstream handlers can retrieve them. This is the idiomatic way to pass request-scoped data.
The standard library now has all you need for advanced routing in Go.
The 'Dreams of Code' video also covers these two advanced topics elegantly: chaining middleware and using the context to pass data.
Watch from 09:04 to 13:27, which covers three key ideas: Middleware Chaining (09:04-10:02): See how a createStack function can make applying multiple middleware much cleaner. Sub-routers (10:48-11:39): A very practical pattern where you apply middleware to a whole group of routes (e.g., an /admin section). Passing Data with Context (11:39-13:27): This is the most important part. Pay close attention to how context.WithValue and r.WithContext are used to pass a user ID from the auth middleware to the handler.
Let's modify our authMiddleware to extract a "user" from the API key and pass it down via the context.
package main
import (
"context" // Import the context package
"log"
"net/http"
"strings"
"time"
)
// Define a custom type for our context key to avoid collisions.
type contextKey string
const userContextKey = contextKey("user")
// ... (keep loggingMiddleware, healthCheckHandler, secretDataHandler) ...
// authMiddleware now extracts a user and adds it to the context.
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
// Let's pretend our key is "super-secret-key:user-123"
if !strings.HasPrefix(apiKey, "super-secret-key:") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
parts := strings.Split(apiKey, ":")
if len(parts) != 2 {
http.Error(w, "Malformed API Key", http.StatusBadRequest)
return
}
user := parts[1]
// Add the user to the request's context.
ctx := context.WithValue(r.Context(), userContextKey, user)
// Create a new request with the updated context and pass it down.
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// secretDataHandler now retrieves the user from the context.
func secretDataHandler(w http.ResponseWriter, r *http.Request) {
// Retrieve the user from the context.
user, ok := r.Context().Value(userContextKey).(string)
if !ok {
// This should not happen if authMiddleware is correctly applied.
http.Error(w, "Could not retrieve user from context", http.StatusInternalServerError)
return
}
log.Printf("User '%s' accessed secret data", user)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data": "this is top secret"}`))
}
// ... (keep the main function, it doesn't need to change) ...
Now, when you run this and make a successful request, you'll see a log line like User 'user-123' accessed secret data, demonstrating that the handler received the data from the middleware.
curl -i -H "X-API-Key: super-secret-key:user-123" http://localhost:8080/secret
Conclusion
You have now mastered the middleware pattern in Go, a versatile and powerful tool for building clean and maintainable web services. By separating cross-cutting concerns from your business logic, you can build applications that are easier to test, reason about, and extend.
Key Takeaways:
- The Middleware Signature: Middleware is a function that accepts an
http.Handlerand returns anhttp.Handler, typically in the formfunc(next http.Handler) http.Handler. - Centralized Logic: You can apply middleware globally by wrapping the main
ServeMuxor apply it to specific routes or groups of routes. - Logging: Middleware is ideal for creating a centralized logger that records details about every request.
- Authentication: The "guard" pattern in middleware allows you to protect endpoints by checking credentials and returning early if they are invalid.
- Passing Data: Use the request's
contextto pass request-scoped data, like a user ID from an auth middleware, down to your handlers.
Next Up
Our services are becoming more robust, but what happens when a handler panics due to an unexpected error, like a nil pointer dereference? Right now, the server would crash that specific request's goroutine and the client would get a disconnected, empty response. In the next lesson, we will build on what we've learned today and implement a centralized error handling middleware to gracefully recover from panics, log the error, and return a clean JSON error response to the user.