DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a Go Middleware Chain for Authentication and Rate Limiting

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
}
Enter fullscreen mode Exit fullscreen mode

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))
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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"))
}
Enter fullscreen mode Exit fullscreen mode

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)