Most Go HTTP servers accumulate authentication logic and rate limiting code directly in handler functions, and it becomes messy fast. When you need to enforce JWT validation and per-IP rate limiting consistently across dozens of endpoints, inline checks don't scale — you end up copy-pasting guard code or missing it entirely on new routes.
Middleware chains solve this cleanly. Here's how to build one properly in Go.
How Go Middleware Works
In Go's net/http, middleware is a function that wraps an http.Handler and returns a new http.Handler:
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
Applying in reverse order ensures the first middleware you list runs first. Get it wrong and your auth check fires after business logic — a common bug that's invisible until production.
JWT Authentication Middleware
The authentication middleware needs to extract the Authorization: Bearer <token> header, validate the JWT signature and expiry, and inject the parsed claims into the request context so handlers can read them.
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const ClaimsKey contextKey = "claims"
type Claims struct {
UserID string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func JWTAuth(secretKey []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "missing or invalid Authorization header", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secretKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
One check you must not skip: validating the signing method. If you omit the if _, ok := t.Method.(*jwt.SigningMethodHMAC) guard, an attacker can craft a token with alg: none and bypass signature verification entirely — a classic JWT attack that still shows up in the wild.
For production, also validate the iss and aud claims via jwt.WithIssuer() and jwt.WithAudience() options. These are easy to forget and can allow token reuse across services that share a secret. If you're building an API that also needs TLS policies, HSTS and security headers, the security hardening checklists at ayinedjimi-consultants.fr cover the full HTTP hardening surface.
Rate Limiting Middleware
A per-IP token bucket limiter. Each IP gets its own bucket that refills at a fixed rate; requests are rejected when the bucket is empty.
package middleware
import (
"net"
"net/http"
"sync"
"time"
)
type bucket struct {
tokens float64
maxTokens float64
rate float64
lastCheck time.Time
mu sync.Mutex
}
func (b *bucket) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
elapsed := now.Sub(b.lastCheck).Seconds()
b.lastCheck = now
b.tokens += elapsed * b.rate
if b.tokens > b.maxTokens {
b.tokens = b.maxTokens
}
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}
type RateLimiter struct {
buckets map[string]*bucket
mu sync.RWMutex
rate float64
burst float64
}
func NewRateLimiter(ratePerSec, burstSize float64) *RateLimiter {
return &RateLimiter{
buckets: make(map[string]*bucket),
rate: ratePerSec,
burst: burstSize,
}
}
func (rl *RateLimiter) getBucket(ip string) *bucket {
rl.mu.RLock()
b, ok := rl.buckets[ip]
rl.mu.RUnlock()
if !ok {
rl.mu.Lock()
b = &bucket{
tokens: rl.burst,
maxTokens: rl.burst,
rate: rl.rate,
lastCheck: time.Now(),
}
rl.buckets[ip] = b
rl.mu.Unlock()
}
return b
}
func (rl *RateLimiter) Middleware() func(http.Handler) http.Handler {
return func(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.getBucket(ip).allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
Two things worth noting: the double-checked locking pattern (RLock to read, Lock to write) avoids unnecessary write contention when most IPs already have a bucket. And r.RemoteAddr gives you the direct connection address — if you're behind a load balancer or a CDN, use X-Real-IP instead, but only after verifying the header comes from a trusted proxy, otherwise clients can spoof their IP and bypass per-IP limits entirely.
In long-running services, the buckets map grows unbounded. Add a background goroutine that evicts entries where lastCheck is older than your idle threshold (e.g. 10 minutes).
Wiring the Chain Together
package main
import (
"fmt"
"net/http"
"yourmodule/middleware"
)
func main() {
limiter := middleware.NewRateLimiter(10, 30) // 10 req/s, burst of 30
secretKey := []byte("load-this-from-env-not-hardcoded")
protected := middleware.Chain(
http.HandlerFunc(userHandler),
limiter.Middleware(),
middleware.JWTAuth(secretKey),
)
public := middleware.Chain(
http.HandlerFunc(healthHandler),
limiter.Middleware(),
)
mux := http.NewServeMux()
mux.Handle("/api/user", protected)
mux.Handle("/health", public)
fmt.Println("Listening on :8080")
http.ListenAndServe(":8080", mux)
}
func userHandler(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.ClaimsKey).(*middleware.Claims)
fmt.Fprintf(w, "Hello user %s (role: %s)", claims.UserID, claims.Role)
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
Rate limiting fires before authentication in this chain — intentional. You don't want to burn CPU on JWT validation for clients already over their quota. Order middleware from cheapest to most expensive.
What to Watch Out For
Distributed deployments: per-instance in-memory buckets mean a client hitting 10 instances gets 10x the allowed rate. For multi-instance deployments you need a shared store — Redis INCR + EXPIRE is the standard approach, or use a sidecar-based solution.
Clock skew in JWT validation: if your token issuer and API server have clock drift, valid tokens can appear expired. The golang-jwt library supports jwt.WithLeeway(5*time.Second) to absorb small differences.
Panic recovery: none of this code handles panics in downstream handlers. Add a recovery middleware at the outermost layer if uptime matters.
Testing: each middleware is testable in isolation by passing a simple http.HandlerFunc as the wrapped handler and checking the response. Don't skip this — auth middleware bugs are silent until they aren't.
The Takeaway
Go's http.Handler interface is deliberately minimal, and middleware chains are just function composition. The pattern is easy to understand, easy to test, and stays readable as you add layers.
The key discipline: keep each middleware focused on one concern, order them by cost (cheapest first), push shared state into closures so handlers stay pure. That covers most of what you need for a solid production middleware stack.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)