← Back to the blog

Middleware in Go: auth, logging, rate limits

Go middleware is just a function that wraps a handler. That simplicity is a feature — here are the three patterns every Go backend needs, implemented cleanly.

Go middleware is a function that takes an http.Handler and returns an http.Handler. That simplicity is the whole design. No framework-specific interface, no decorator pattern, no magic — just function composition. Here are the three middleware pieces every Go backend needs and how to implement them cleanly.

Quick answerGo middleware signature: func(http.Handler) http.Handler. Apply middleware in reverse order of execution — the first one you pass wraps the outermost layer.

How middleware works in Go

// The middleware type.
type Middleware func(http.Handler) http.Handler

// A minimal example: add a response header.
func ServerHeader(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("X-Server", "taqnihub/api")
    next.ServeHTTP(w, r)
  })
}

The pattern is always the same: do something before the handler, call next.ServeHTTP, optionally do something after. Short-circuit by returning without calling next.

Auth middleware

Auth middleware validates the token, injects the user ID into the context, and rejects the request if the token is missing or invalid.

type ctxKey string
const ctxKeyUserID ctxKey = "user_id"

func RequireAuth(secret string) Middleware {
  return func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
      if raw == "" {
        writeError(w, http.StatusUnauthorized, "missing_token", "Authorization required.", nil)
        return
      }
      claims, err := auth.ParseToken(raw, secret)
      if err != nil {
        writeError(w, http.StatusUnauthorized, "invalid_token", "Token is invalid or expired.", nil)
        return
      }
      ctx := context.WithValue(r.Context(), ctxKeyUserID, claims.UserID)
      next.ServeHTTP(w, r.WithContext(ctx))
    })
  }
}

// Helper for handlers to read the user ID.
func UserIDFromCtx(ctx context.Context) (string, bool) {
  id, ok := ctx.Value(ctxKeyUserID).(string)
  return id, ok
}

Structured logging middleware

A response writer wrapper captures the status code for logging — the standard http.ResponseWriterdoesn't expose it after the fact.

type statusWriter struct {
  http.ResponseWriter
  status int
}
func (sw *statusWriter) WriteHeader(code int) {
  sw.status = code
  sw.ResponseWriter.WriteHeader(code)
}

func Logger(logger *slog.Logger) Middleware {
  return func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      start := time.Now()
      sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
      next.ServeHTTP(sw, r)
      logger.Info("request",
        "method", r.Method,
        "path",   r.URL.Path,
        "status", sw.status,
        "dur_ms", time.Since(start).Milliseconds(),
      )
    })
  }
}

Rate limiting

A token-bucket limiter per IP using golang.org/x/time/rate. Store a limiter per IP in a sync.Map to avoid a global mutex on the hot path.

func RateLimiter(rps float64, burst int) Middleware {
  var clients sync.Map

  getLimiter := func(ip string) *rate.Limiter {
    v, _ := clients.LoadOrStore(ip, rate.NewLimiter(rate.Limit(rps), burst))
    return v.(*rate.Limiter)
  }

  return func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      ip, _, _ := net.SplitHostPort(r.RemoteAddr)
      if !getLimiter(ip).Allow() {
        writeError(w, http.StatusTooManyRequests, "rate_limit_exceeded", "Too many requests.", nil)
        return
      }
      next.ServeHTTP(w, r)
    })
  }
}

Middleware ordering

Middleware executes in the order it's applied. Apply outermost-first — recovery and logging wrap everything; auth applies only to protected routes.

r.Use(Recoverer)          // 1st — catch panics before logging
r.Use(Logger(logger))    // 2nd — log every request
r.Use(RateLimiter(10, 50)) // 3rd — reject excess traffic
// RequireAuth applied per-route or per-group:
r.Group(func(r chi.Router) {
  r.Use(RequireAuth(cfg.JWTSecret))
  r.Get("/me", h.GetProfile)
})

See the HTTP server post for the router setup that hosts these, and the JWT auth post for the token logic behind RequireAuth.

★ ★ ★

End of article · Thanks for reading

Subscribe

More of this, once a month.