Composable HTTP middleware, utility primitives, and server lifecycle helpers for Go — CORS, client IP extraction, response recording, middleware chaining, security headers, CSP nonce support, request ID, panic recovery, timeout enforcement, structured logging, response compression, request body decompression with bomb protection, ETag conditional requests (via go-etag composition), W3C Server-Timing, CSRF protection (nosurf), keyed rate limiting, content-language negotiation, configurable HTTP server, standard health checks, and — from the separate endpoints module — a self-describing /endpoints route listing.
Minimal footprint — four dependencies (go-error-family + go-etag same-author, golang.org/x/time, justinas/nosurf). Pure stdlib net/http. Go 1.27+ (the go directive requires 1.27.1+ since go-etag v0.5.0).
go get github.com/larsartmann/httputilpackage main
import (
"log/slog"
"net/http"
"time"
"github.com/larsartmann/httputil"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
requestID, _ := httputil.RequestIDFromContext(r.Context())
slog.Info("handled", "path", r.URL.Path, "request_id", requestID)
w.WriteHeader(http.StatusOK)
})
handler := httputil.Chain(
mux,
httputil.Recovery(slog.Default()),
httputil.Logging(slog.Default()),
httputil.Timeout(30 * time.Second),
httputil.RequestID(httputil.DefaultRequestIDConfig()),
httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig()),
httputil.CORS(httputil.DefaultCORSConfig()),
)
http.ListenAndServe(":8080", handler)
}Configurable Cross-Origin Resource Sharing with sensible defaults.
// Permissive config for local development (allows all origins)
handler := httputil.CORS(httputil.DefaultCORSConfig())(mux)
// Restrict to specific origins in production
cfg := httputil.CORSConfig{
AllowedOrigins: []string{"https://myapp.com", "*.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 3600,
}
handler := httputil.CORS(cfg)(mux)Preflight OPTIONS requests receive 204 No Content automatically. Set OptionsPassthrough to forward them to your handler instead. Wildcard patterns like *.example.com match subdomains. Set AllowPrivateNetwork: true to answer Chrome's Local Network Access preflights with Access-Control-Allow-Private-Network: true (needed when pages from a less-private network fetch LAN or localhost subresources).
Use cfg.Validate() to catch invalid configurations at startup (e.g., AllowCredentials: true with AllowAllOrigins: true which browsers reject).
Extracts the real client IP behind reverse proxies using standard header precedence.
func handler(w http.ResponseWriter, r *http.Request) {
ip := httputil.ClientIP(r)
fmt.Fprintf(w, "Your IP: %s", ip)
}Resolution order: X-Forwarded-For (first entry) → X-Real-IP → RemoteAddr.
Security:
ClientIPtrusts proxy headers without validation. Only use behind a reverse proxy that strips or overwrites these headers.
Context helpers are available for downstream access:
handler := httputil.ClientIPMiddleware(next)
// In downstream handler:
ip := httputil.ClientIPFromContext(r.Context())Wraps http.ResponseWriter to capture the status code for logging, metrics, or auditing.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := httputil.NewResponseRecorder(w)
next.ServeHTTP(rec, r)
log.Printf("%s %s → %d", r.Method, r.URL.Path, rec.Status())
})
}ResponseRecorder transparently supports http.Flusher and http.Hijacker when the underlying writer implements them. Write errors carry classified error codes via go-error-family for retry decisions.
HeaderSnapshot() returns an isolated copy of response headers for inspection:
headers := rec.HeaderSnapshot()Compose multiple middleware into a single handler. First middleware in the list is outermost.
handler := httputil.Chain(
mux,
httputil.CORS(httputil.DefaultCORSConfig()),
authMiddleware,
loggingMiddleware,
)
// Execution: CORS → auth → logging → muxValidate any http.Handler against standard HTTP conventions with a single call. The httpspec subpackage runs 19 behavioral specs as parallel subtests.
import "github.com/larsartmann/httputil/httpspec"
func TestHTTPBehavior(t *testing.T) {
t.Parallel()
httpspec.Run(t, handler)
}Specs checked: index page reachability, unknown path 404s, long URL handling, POST safety, Content-Type on bodies and errors, HEAD/OPTIONS/TRACE/CONNECT handling, redirect Location correctness, no duplicate headers, Accept header handling, no leaked internals, no Server version fingerprinting, no X-Powered-By header, X-Content-Type-Options: nosniff presence, no injection-header reflection (a client-supplied X-CSRF-Token/probe header value must never be echoed back in a response header). Opt-in spec families add targeted contract checks — httpspec.LanguageSpecs() pins that language-negotiating handlers stamp Content-Language and list Accept-Language in Vary, while httpspec.TracingSpecs() pins W3C trace-context tolerance (a valid traceparent or tracestate never causes a 5xx; a malformed one behaves like an absent header). httpspec.RunTracing(t, middleware) goes further and verifies the middleware layer itself: header and r.Pattern passthrough, absent-header invariance, request invariance (method/path/body), response passthrough, and Flusher/Pusher/Hijacker preservation. Use RunSerial for handlers with shared state. Skip inapplicable specs or add custom ones:
httpspec.Run(t, handler,
httpspec.SkipSpec(httpspec.SpecNameUnknownPathReturns404), // SPA fallback
httpspec.WithExtraSpecs(httpspec.Spec{
Name: "GET /health returns 200",
Category: httpspec.CategoryRouting,
Check: httpspec.ExpectStatus(http.MethodGet, "/health", http.StatusOK),
}),
)Sets common security response headers with sensible defaults.
handler := httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig())(mux)Headers set by default: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin.
Generates a per-request cryptographic nonce for Content Security Policy, allowing specific inline <script> and <style> elements to execute while blocking all others — no 'unsafe-inline' needed.
handler := httputil.Nonce(httputil.DefaultNonceConfig())(mux)Retrieve the nonce in handlers or templates:
// In a handler:
nonce := httputil.NonceFromRequest(r)
// In a Go template:
// <script {{ NonceAttr }}>...</script>
// <style {{ NonceAttr }}>...</style>
attr := httputil.NonceAttr(r) // returns nonce="abc123"Two built-in CSP builders:
RecommendedCSPWithNonce(default):script-srcandstyle-srcwith nonce.ProductionCSPWithNonce: addsobject-src 'none',base-uri 'self',frame-ancestors 'none'.
cfg := httputil.DefaultNonceConfig()
cfg.CSPBuilder = httputil.ProductionCSPWithNonce // stricter policy
handler := httputil.Nonce(cfg)(mux)Use a custom CSP by setting CSPBuilder, or set it to nil to disable the header entirely (context-only mode for when you set CSP elsewhere).
Ordering: Place
NonceafterSecurityHeadersin the chain so the nonce-bearing CSP overwrites any static CSP. The defaultSecurityHeadersConfigdoes not set a CSP, so there is no conflict unless you explicitly setContentSecurityPolicyin both.
Caching: Responses with per-request nonces must not be cached — a cached page would serve a stale nonce that no longer matches the CSP header. Set
Cache-Control: no-storein your handler or caching middleware when using nonce-based CSP.
Propagates or generates a request ID per request.
handler := httputil.RequestID(httputil.DefaultRequestIDConfig())(mux)
// Access in downstream handlers:
id := httputil.RequestIDFromContext(r.Context())Catches panics, logs the stack trace, and returns 500 Internal Server Error.
handler := httputil.Recovery(slog.Default())(mux)RecoveryWithConfig adds a nil-default OnPanic hook for observability integrations. It runs after the panic is logged and before the 500 is written — late enough to know the panic happened, early enough for the span to still be recording:
handler := httputil.RecoveryWithConfig(httputil.RecoveryConfig{
Logger: logger,
OnPanic: func(r *http.Request, recovered any) {
span := trace.SpanFromContext(r.Context())
span.RecordError(fmt.Errorf("panic: %v", recovered))
span.SetStatus(codes.Error, "panic")
},
})(mux)A hook that panics is logged and swallowed, so a buggy integration cannot replace the recovery path. Recovery(logger) is exactly RecoveryWithConfig{Logger: logger} and is unchanged. Full OpenTelemetry wiring: docs/integrations/otel.md.
Enforces a deadline on the request context. The handler must respect context cancellation.
handler := httputil.Timeout(30 * time.Second)(mux)Logs each request with method, path, status, duration, and client IP.
handler := httputil.Logging(slog.Default())(mux)For per-request loggers (per tenant, per trace), resolve the logger from the request context instead; requests whose context carries no logger fall back to the given logger, and requests with neither are served without a log line:
handler := httputil.LoggingFromContext(slog.Default(), LoggerFromContext)(mux)Transparent response compression negotiated from the client's Accept-Encoding header. Supports gzip and deflate out of the box, respects RFC 7231 q-values, and leaves small responses, non-2xx statuses, and already-encoded responses uncompressed. Requests without an Accept-Encoding header are served uncompressed by default (minimal clients and probes may not decompress); set AbsentEncoding: httputil.AbsentEncodingFirstConfigured to restore the pre-v1.2 behavior of compressing for header-less clients. Vary: Accept-Encoding is added to every response — compressed or not — so shared caches key on the negotiation instead of serving a compressed representation to a client that cannot decode it. Requests carrying a Range header always pass through uncompressed — byte-offset responses must be the verbatim representation, so compressing them would break range semantics.
handler := httputil.Compression(httputil.DefaultCompressionConfig())(mux)Use cfg.Validate() to catch invalid configurations at startup (e.g., invalid compression levels or negative minimum sizes).
To add encodings not bundled with this package (brotli, zstd, lz4), register a WriterFactory:
cfg := httputil.DefaultCompressionConfig()
cfg.WriterFactories["br"] = func(dst io.Writer) (io.WriteCloser, error) {
return brotli.NewWriterLevel(dst, brotli.DefaultCompression), nil
}
handler := httputil.Compression(cfg)(mux)DefaultWriterFactories() returns a fresh map with gzip, deflate, and identity entries so you can extend rather than replace the built-ins.
Transparently decompresses request bodies based on the Content-Encoding header. Supports gzip and deflate. The middleware replaces r.Body with a decompressing reader and removes Content-Encoding and Content-Length headers so downstream handlers see the decompressed body transparently.
handler := httputil.Decompression(httputil.DefaultDecompressionConfig())(mux)Bomb protection: The decompressed body is limited to MaxDecompressionSize bytes (default: 16 MiB) to prevent decompression bomb attacks. When the limit is exceeded, reads return an error and the underlying reader is closed immediately. 0 is not an off switch: it selects the same 16 MiB default, so bomb protection cannot be accidentally disabled.
Restrict which encodings are accepted by setting Encodings:
cfg := httputil.DecompressionConfig{
Encodings: []string{"gzip"}, // deflate-only servers skip this
MaxDecompressionSize: 1 << 20, // 1 MiB limit
}
handler := httputil.Decompression(cfg)(mux)Use cfg.Validate() to catch invalid configurations at startup (e.g., negative MaxDecompressionSize).
ETag generation and If-None-Match handling via the go-etag module. The middleware buffers GET/HEAD response bodies, computes an FNV-64a hash, and returns 304 Not Modified when the client's If-None-Match matches.
Pair ETags with explicit
Cache-Control; withoutVaryon negotiated representations, caches may serve mismatched bodies. Cache policy belongs to the application (or a header middleware), conditional-request semantics to go-etag.
import "github.com/larsartmann/go-etag"
handler := etag.New(etag.DefaultETagConfig())(mux)For domain types (etag.ETag, etag.ParseETag, etag.MatchesIfNoneMatch, etc.), import go-etag directly. etag.New returns func(http.Handler) http.Handler, which composes directly with Chain and MiddlewareStack.
Ready-made counters for go-etag's observability hooks (OnETagGenerated, On304, OnBufferOverflow) live in go-etag itself: github.com/larsartmann/go-etag/metrics (metrics.Attach(cfg) returns the config plus atomic counters with a HitRatio() accessor). The package moved out of this repo (it shipped here briefly as the etagmetrics sub-module in v1.3.0) to live next to the hooks it counts.
A configurable http.Server wrapper with sensible timeout defaults and lifecycle helpers. Errors from Start/StartTLS arrive only after the failure is fully settled (listener closed, ListenerAddr reports not-listening), so restarting the same server after a failure is always safe; starting a listening server returns the classified server.already_started error instead of binding an orphan listener.
cfg := httputil.DefaultServerConfig()
cfg.Addr = ":8080"
srv, err := httputil.NewServer(cfg, handler)
if err != nil {
log.Fatal(err)
}
errChan := srv.Start()
// Wait for shutdown signal, then call srv.Shutdown(ctx).
// HTTPS: srv.StartTLS("cert.pem", "key.pem") applies cfg.TLSConfig (TLS 1.2+ enforced).Wrappers that need to own the bind (synchronous bind-failure classification, readiness observable before serving, socket-activated or unix-socket listeners) inject an already-bound listener instead:
ln, err := net.Listen("tcp", "127.0.0.1:0") // bind failures classify HERE, synchronously
if err != nil {
log.Fatal(err)
}
srv, err := httputil.NewServerListener(ln, cfg, handler) // Addr is derived from ln
if err != nil {
log.Fatal(err)
}
errChan := srv.Start() // serves on ln, never binds; StartTLS wraps it for HTTPSThe injected listener is owned by the Server from construction (ListenerAddr
reports it before Start): net/http closes it when serving ends, so a
NewServerListener server is single-serve — a restart after Shutdown returns
the classified server.listener_consumed error. Bind a fresh listener and build
a new Server to serve again.
Default timeouts match production recommendations:
ReadTimeout: 10sReadHeaderTimeout: 5sWriteTimeout: 30sIdleTimeout: 60s
Stdlib pass-through boundary:
ServerConfigwraps the common timeouts and TLS settings; otherhttp.Serverknobs (for example Go 1.27's newMaxHeaderValueCountrequest-header cap) are not exposed — construct a plainhttp.Serverwhen you need one.
Standard Kubernetes-compatible health handlers.
mux := http.NewServeMux()
httputil.RegisterHealth(mux)
// Registers:
// GET /health
// GET /health/live
// GET /health/readyOr use individual handlers:
mux.HandleFunc("GET /health", httputil.HealthHandler())For dependency-based readiness (e.g., database connectivity):
mux.HandleFunc("GET /health/ready", httputil.ReadyHandlerWithProbe(db.Ping))These handlers are the zero-dependency floor. For real probes — critical/non-critical classification, background caching, shutdown-aware readiness, aggregate and federation — pair httputil with go-health behind the same middleware; see Using httputil with go-health.
Double-submit cookie CSRF protection via justinas/nosurf with HTMX-aware helpers.
handler := httputil.CSRFMiddleware(httputil.CSRFConfig{})(mux)For HTMX, pair with CSRFResponseHeaderMiddleware to automatically set the token in response headers:
handler := httputil.Chain(mux,
httputil.CSRFMiddleware(httputil.CSRFConfig{}),
httputil.CSRFResponseHeaderMiddleware,
)Token helpers for templates: CSRFTokenFormField (hidden input), CSRFTokenHTMLMeta (meta tag), CSRFTokenHXHeaders (hx-headers attribute).
Call InvalidateCSRFCookie(w, cfg) on login/logout to rotate the token.
Origin attestation trust model. nosurf (verified at v1.2.0) skips Origin/Referer validation whenever a request carries a literal Sec-Fetch-Site: same-origin header. Browsers set that header truthfully and JavaScript cannot forge it (forbidden header name), so it is reliable against the classic cross-site attacker — but any non-browser client can send it manually. Two deliberate consequences:
- A client-supplied attestation (or the plaintext-proxy bypass,
SetPlaintextHTTPOrigin) skips only the origin check; the masked-token check still gates every state-changing request. This is what lets non-browser API clients — which cannot pass Origin/Referer validation on plain HTTP — work with valid tokens. - A client-supplied attestation contradicted by an
Originheader from a different, untrusted origin is rejected withErrCSRFAttestationConflictbefore nosurf sees the request. Browsers never produce that combination, so it can only be forged. Thenullorigin is exempt (nosurf treats it as absent).
Migrating (behavior change in v1.0.0): a non-browser client that sent a forged Sec-Fetch-Site: same-origin alongside an Origin from a different host previously slipped past nosurf's origin validation (the attestation short-circuit) and only had to pass the token check. Such requests now get 403 with the csrf.origin_attestation_conflict error code before nosurf runs. Fix the client, not the middleware: send no Sec-Fetch-Site header at all (nosurf then applies normal Origin/Referer validation), or send a truthful attestation, or add the client's real origin to CSRFConfig.TrustedOrigins.
Plaintext-HTTP origin bypass: requests with no Origin/Referer/Sec-Fetch-Site header are auto-marked same-origin only when they arrive from loopback or TrustedProxies (or from everywhere with AllowPlaintextBypass, which logs a warning — it is insecure for internet-facing plain HTTP).
W3C Server-Timing header with per-request sub-metrics. Lives in the server_timing sub-module (github.com/larsartmann/httputil/server_timing, package servertiming), which is MIT-licensed and has its own README.
import "github.com/larsartmann/httputil/server_timing"
handler := servertiming.ServerTimingMiddleware()(mux)
// In a handler:
stop := servertiming.MeasureServerTiming(r.Context(), "db")
result, err := db.Query(ctx)
stop()Gate behind a predicate to expose only for admin/debug requests:
handler := servertiming.ServerTimingMiddlewareWhen(func(r *http.Request) bool {
return r.URL.Query().Has("debug")
})(mux)A GET /endpoints listing of every route the server serves — JSON for API clients, an HTML table for browsers, picked by content negotiation on the Accept header. Lives in the endpoints sub-module (github.com/larsartmann/httputil/endpoints), which is MIT-licensed, stdlib-only with zero dependencies, and has its own README.
*http.ServeMux cannot enumerate the patterns registered on it, so the listing needs its own source of truth: every route registered through a Registry is mounted on the mux AND recorded in one declaration, so the listing cannot drift from what is actually served.
import "github.com/larsartmann/httputil/endpoints"
routes := endpoints.NewRegistry()
mux := http.NewServeMux()
// One declaration: mounts "GET /users/{id}" AND records it for the listing.
err := routes.HandleFunc(mux, "GET /users/{id}", getUser, "Fetch one user")
if err != nil {
return err // wiring errors are values: nil mux, empty or duplicate pattern, non-UTF-8 summary
}
// Mount the listing itself — it lists itself too.
if err := endpoints.Register(mux, routes); err != nil {
return err
}Negotiation never 406s: an absent, unknown, or garbage Accept header serves JSON (json/v2); text/html or text/* serves the HTML table; q=0 excludes a representation and ties go to JSON. Both representations carry Vary: Accept + Cache-Control: no-store; methods other than GET/HEAD get 405 with Allow. The HTML is CSP-clean (no inline styles or scripts; every field escaped), so it composes with SecurityHeaders and Nonce. A route listing documents the attack surface — mount it deliberately if the service is internet-facing.
Token-bucket rate limiting per client key with O(log n) min-heap eviction and monitoring.
handler := httputil.KeyedRateLimiterMiddleware(httputil.DefaultKeyedRateLimiterConfig())(mux)For monitoring active keys:
rl := httputil.NewKeyedRateLimiter(httputil.DefaultKeyedRateLimiterConfig())
fmt.Println(rl.ActiveKeys())
handler := rl.Middleware()(mux)Rejected requests receive 429 Too Many Requests with a Retry-After header.
Behind a reverse proxy that appends to X-Forwarded-For (rather than stripping it), KeyExtractorFromClientIP() lets clients forge their rate-limit key. Use the trust-gated extractor instead — forwarded headers are honored only when the socket peer is inside the trusted CIDR set, and the list is walked right-to-left so a forged leftmost entry cannot outrank the honest one:
extractor, err := httputil.KeyExtractorFromTrustedClientIP([]string{"10.0.0.0/8"})
if err != nil {
return err // invalid CIDR, fix the configuration
}
cfg := httputil.DefaultKeyedRateLimiterConfig()
cfg.KeyExtractor = extractorParseTrustedProxies(nil) builds the same value with the loopback-only default (local proxies and tests work with zero config); the returned TrustedProxySet also exposes ClientIP(r) and Contains(ip) for non-rate-limit consumers (vote guards, logging).
Negotiates the response language from Accept-Language (or a query param, cookie, or URL path prefix) against the app's supported tags, then stamps Content-Language and Vary: Accept-Language and exposes the served tag to handlers via the request context:
cfg := httputil.DefaultLanguageConfig()
cfg.SupportedTags = []string{"en", "de"}
handler := httputil.Language(cfg)(mux)The default matcher falls back by primary subtag (de-AT serves de); supply a TagMatcher for full BCP 47 matching via golang.org/x/text. Extractors compose with LanguageExtractorChain — the first extractor producing a supported tag wins, so a cookie or path-prefix leg can outrank the header.
Ordering: Place
LanguageoutsideCSRFMiddleware(the 403 page can localize, which needs the language context resolved) and insideCORS(CORS must stay outermost to answer preflights). Declared tag spellings are preserved —zh-Hansis served aszh-Hans; only comparisons are case-insensitive. ETag must also sit inside (after)Language: an ETag computed before language selection would let a cache serve language A's body to language B's conditional request.
handler := httputil.Chain(
mux,
httputil.CORS(httputil.DefaultCORSConfig()), // outermost: answers preflights
httputil.CSRFMiddleware(httputil.CSRFConfig{}),
httputil.Language(cfg),
etagMiddleware, // innermost of the pair: hashes AFTER language selection
)ResponseRecorder, compressWriter, and CSRFMiddleware errors are classified with behavioral families via go-error-family:
| Source | Error Code | Family | Retryable | When |
|---|---|---|---|---|
Write |
http.write_failed |
Transient | Yes | Underlying ResponseWriter.Write fails |
Hijack |
http.hijack_unsupported |
Infrastructure | No | Underlying writer doesn't implement Hijacker |
Hijack |
http.hijack_failed |
Transient | Yes | Underlying Hijack call fails |
Compress |
http.compress_write_failed |
Transient | Yes | Compression writer Write/Close fails |
CSRF |
csrf_invalid |
Rejection | No | CSRF token missing, malformed, or mismatched |
CSRF |
csrf.origin_attestation_conflict |
Rejection | No | Sec-Fetch-Site: same-origin attestation contradicted by a cross-origin Origin header (forged attestation) |
CSRF |
csrf_config |
Infrastructure | No | CSRF configuration invalid |
CSRF |
csrf.max_age_negative |
Rejection | No | CSRFConfig.MaxAge is negative |
CSRF |
csrf.trusted_origin_invalid |
Rejection | No | TrustedOrigins entry is not a usable scheme://host origin |
CORS |
cors.methods_empty |
Rejection | No | CORSConfig.AllowedMethods is empty |
Language |
language.* |
Rejection | No | LanguageConfig invalid (empty tags, invalid tag charset, unsupported default, nil extractor) |
Compress |
compression.incompressible_prefix_invalid |
Rejection | No | IncompressibleTypes entry is empty or lacks a slash |
Compress |
compression.absent_encoding_invalid |
Rejection | No | AbsentEncoding is not a known AbsentEncodingPolicy constant |
Decompress |
decompression.encoding_unrecognized |
Rejection | No | Encodings entry other than gzip/deflate |
Decompress |
decompression.encoding_duplicate |
Rejection | No | Same encoding listed twice |
Ratelimit |
ratelimit.* |
Rejection | No | KeyedRateLimiterConfig invalid (zero limit/window, negative TTL, Burst above math.MaxInt32) |
Call RegisterErrorClassifications() at startup to enable classification of stdlib HTTP errors and register error message templates.
Conditional-request outcomes have upstream classification guidance in go-error-family (README, "Conditional Requests"): 428 Precondition Required is a Rejection (an omitted precondition is incomplete input), 412 Precondition Failed is a Conflict via NewConflict(...).WithHTTPStatus(412) (a state clash — refresh and reapply), and 304 Not Modified is a success-path response, not an error — which is how the ETag middleware above produces it.
| Function | Signature | Purpose |
|---|---|---|
CORS |
func(CORSConfig) func(http.Handler) http.Handler |
CORS middleware factory |
DefaultCORSConfig |
func() CORSConfig |
Permissive dev config (allows all origins) |
ClientIP |
func(*http.Request) string |
Extract client IP from proxied request |
ClientIPMiddleware |
func(http.Handler) http.Handler |
Store client IP in request context |
ClientIPFromContext |
func(context.Context) string |
Retrieve stored client IP |
WithClientIP |
func(context.Context, string) context.Context |
Store client IP in context |
NewResponseRecorder |
func(http.ResponseWriter) *ResponseRecorder |
Wrap writer to capture status |
Chain |
func(http.Handler, ...func(http.Handler) http.Handler) http.Handler |
Compose middleware |
Compose |
func(...Middleware) Middleware |
Reusable middleware bundle (first = outermost) |
SecurityHeaders |
func(SecurityHeadersConfig) func(http.Handler) http.Handler |
Security response headers |
DefaultSecurityHeadersConfig |
func() SecurityHeadersConfig |
Sensible security defaults |
RequestID |
func(RequestIDConfig) func(http.Handler) http.Handler |
Request ID propagation/generation |
DefaultRequestIDConfig |
func() RequestIDConfig |
Default X-Request-ID config |
RequestIDFromContext |
func(context.Context) string |
Retrieve stored request ID |
Recovery |
func(*slog.Logger) func(http.Handler) http.Handler |
Panic recovery |
RecoveryWithConfig |
func(RecoveryConfig) Middleware |
Panic recovery with an OnPanic observability hook |
Timeout |
func(time.Duration) func(http.Handler) http.Handler |
Request deadline enforcement |
Logging |
func(*slog.Logger) func(http.Handler) http.Handler |
Structured request logging |
LoggingFromContext |
func(*slog.Logger, func(context.Context) *slog.Logger) Middleware |
Request logging with the logger resolved per request from context |
MaxBodySize |
func(int64) func(http.Handler) http.Handler |
Request body size limit |
Compression |
func(CompressionConfig) func(http.Handler) http.Handler |
Negotiated response compression |
DefaultCompressionConfig |
func() CompressionConfig |
gzip/deflate defaults |
DefaultWriterFactories |
func() map[string]WriterFactory |
Built-in gzip/deflate/identity factories |
DefaultWriterFactoriesForLevel |
func(int) map[string]WriterFactory |
Built-in factories at a given compression level |
GzipWriterFactory |
func(int) WriterFactory |
Stdlib gzip factory at a given level |
DeflateWriterFactory |
func(int) WriterFactory |
Stdlib flate/raw-deflate factory |
DefaultIncompressibleTypes |
func() []string |
Default content-type deny-list for compression |
Decompression |
func(DecompressionConfig) func(http.Handler) http.Handler |
Request body decompression + bomb protection |
DefaultDecompressionConfig |
func() DecompressionConfig |
gzip/deflate defaults |
KeyedRateLimiterMiddleware |
func(KeyedRateLimiterConfig) func(http.Handler) http.Handler |
Per-key rate limiting with eviction |
Language |
func(LanguageConfig) func(http.Handler) http.Handler |
Content-language negotiation (Content-Language + Vary stamping) |
NewKeyedRateLimiter |
func(KeyedRateLimiterConfig) *KeyedRateLimiter |
Rate limiter with monitoring API |
DefaultKeyedRateLimiterConfig |
func() KeyedRateLimiterConfig |
Default per-key rate limit config |
KeyExtractorFromTrustedClientIP |
func([]string) (KeyExtractor, error) |
Client-IP keying gated by trusted-proxy CIDRs |
ParseTrustedProxies |
func([]string) (TrustedProxySet, error) |
Parsed, immutable trusted-proxy set (.ClientIP/.Contains/.KeyExtractor) |
CSRFMiddleware |
func(CSRFConfig) func(http.Handler) http.Handler |
CSRF protection middleware |
CSRFResponseHeaderMiddleware |
func(http.Handler) http.Handler |
Auto-set CSRF token in response header |
Nonce |
func(NonceConfig) func(http.Handler) http.Handler |
Per-request CSP nonce (context + optional CSP header) |
DefaultNonceConfig |
func() NonceConfig |
Default CSP nonce config (20 bytes) |
NonceAttr |
func(*http.Request) string |
Ready-to-use nonce="..." HTML attribute |
ValidateCSRF |
func(*http.Request, CSRFConfig) (bool, *httptest.ResponseRecorder) |
Standalone CSRF validation |
ServerTimingMiddleware |
func() func(http.Handler) http.Handler |
W3C Server-Timing middleware (server_timing module) |
ServerTimingMiddlewareWhen |
func(func(*http.Request) bool) func(http.Handler) http.Handler |
Conditional Server-Timing (server_timing module) |
endpoints.NewRegistry |
func() *endpoints.Registry |
Route registry for the /endpoints listing (endpoints module) |
endpoints.Registry.Handle |
func(*http.ServeMux, string, http.Handler, string) error |
Mount a handler on the mux AND record its route |
endpoints.Registry.HandleFunc |
func(*http.ServeMux, string, func(http.ResponseWriter, *http.Request), string) error |
Handle for a bare handler function |
endpoints.Registry.Routes |
func() []endpoints.Route |
Registered routes, sorted defensive copy |
endpoints.Registry.Handler |
func() http.Handler |
The content-negotiated listing handler (GET/HEAD, 405 otherwise) |
endpoints.Register |
func(*http.ServeMux, *endpoints.Registry) error |
Mount the listing at DefaultPattern (GET /endpoints) |
endpoints.Route |
struct { Method, Pattern, Summary string } |
One recorded route |
endpoints.Response |
struct { Endpoints []endpoints.Route } |
Listing payload rendered as JSON or HTML |
endpoints.DefaultPattern |
const "GET /endpoints" |
Pattern Register mounts by default |
ParseUintQuery |
func(*http.Request, string) uint |
Parse uint from query param |
Metrics |
func(MetricsConfig) func(http.Handler) http.Handler |
Request metrics recording |
DefaultMetricsConfig |
func() MetricsConfig |
Default metrics config |
NewMiddlewareStack |
func() *MiddlewareStack |
Named middleware stack builder |
MiddlewareStack.Middleware() |
func() Middleware |
Nest the whole stack as one middleware |
MiddlewareFunc.Then |
func(http.Handler) http.Handler |
Value-level chaining; nil wires a 500-stub handler |
DetectCapabilities |
func(http.ResponseWriter) Capabilities |
Report Hijacker/Flusher support |
RegisterErrorClassifications |
func() |
Register stdlib error sentinels + templates |
NewServer |
func(ServerConfig, http.Handler) (*Server, error) |
Configurable HTTP server with timeouts |
NewServerListener |
func(net.Listener, ServerConfig, http.Handler) (*Server, error) |
Serve on a caller-bound listener (wrappers own the bind) |
Server.ListenerAddr() |
func() (net.Addr, bool) |
Resolved listener address (:0 → real port); false when not listening |
DefaultServerConfig |
func() ServerConfig |
Sensible server timeout defaults |
RegisterHealth |
func(*http.ServeMux) |
Register /health + /live + /ready |
HealthHandler |
func() http.HandlerFunc |
Simple {"status":"up"} handler |
LiveHandler |
func() http.HandlerFunc |
Kubernetes liveness probe handler |
ReadyHandler |
func() http.HandlerFunc |
Kubernetes readiness probe handler |
ReadyHandlerWithProbe |
func(func() bool) http.HandlerFunc |
Readiness with dependency probe (200/503) |
| Field | Type | Default | Description |
|---|---|---|---|
AllowedOrigins |
[]string |
["*"] |
Origins permitted in CORS responses (supports *.example.com) |
AllowedMethods |
[]string |
["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] |
Allowed HTTP methods |
AllowedHeaders |
[]string |
["Content-Type", "Authorization", "X-Request-ID"] |
Accepted request headers |
ExposedHeaders |
[]string |
[] |
Headers the browser may access |
AllowCredentials |
bool |
false |
Whether to send credentials |
MaxAge |
int |
86400 |
Preflight cache duration in seconds |
AllowAllOrigins |
bool |
true |
Respond with * for any origin |
OptionsPassthrough |
bool |
false |
Forward OPTIONS to the next handler |
DenyUnmatched |
bool |
true |
Withhold Allow-Origin for origins not in AllowedOrigins (secure by default) |
AllowPrivateNetwork |
bool |
false |
Answer Chrome Local Network Access preflights (preflight responses only) |
AllowPrivateNetwork is independent of origin matching: a denied-origin preflight (DenyUnmatched, no Allow-Origin) still carries the header — the browser already fails that response at the missing Allow-Origin, so no capability is granted. Note the MaxAge interplay: browsers cache preflight results (including the LNA grant) for Access-Control-Max-Age seconds, so lowering MaxAge also shortens how long an LNA allowance survives a config rollback.
| Method | Returns | Description |
|---|---|---|
Status() |
int |
Captured status code (0 if WriteHeader not called) |
WroteHeader() |
bool |
Whether WriteHeader was called |
HeaderSnapshot() |
http.Header |
Isolated copy of response headers |
WriteHeader(int) |
— | Capture status and delegate |
Write([]byte) |
(int, error) |
Write body, implicitly set 200 |
Flush() |
— | Delegate if underlying writer supports http.Flusher |
Hijack() |
(net.Conn, *bufio.ReadWriter, error) |
Delegate if underlying writer supports http.Hijacker |
| Field | Type | Default | Description |
|---|---|---|---|
MinSize |
int |
512 |
Minimum response body size before compression is attempted |
Level |
int |
gzip.DefaultCompression |
Compression level used when WriterFactories is not supplied; applies to both gzip and deflate |
WriterFactories |
map[string]WriterFactory |
gzip, deflate, identity | Encoding-name → factory map; replace or extend |
IncompressibleTypes |
[]string |
DefaultIncompressibleTypes() |
Content-types to skip (nil = defaults, empty = compress all) |
| Field | Type | Default | Description |
|---|---|---|---|
Encodings |
[]string |
gzip, deflate | Request body encodings to decompress; empty = both defaults |
MaxDecompressionSize |
int64 |
16777216 |
Max decompressed body size in bytes to prevent zip bombs (16 MiB); 0 selects the 16 MiB default (no unlimited option) |
| Field | Type | Default | Description |
|---|---|---|---|
Limit |
uint |
100 |
Maximum requests per Window per key |
Window |
time.Duration |
1m |
Time window for the limit |
Burst |
uint |
0 (= Limit) |
Maximum burst size (can exceed Limit) |
KeyExtractor |
KeyExtractor |
KeyExtractorFromClientIP() |
Extracts the rate-limit key from the request |
TTL |
time.Duration |
10m |
How long idle entries are kept before eviction |
MaxKeys |
uint |
0 (unbounded) |
Caps tracked keys; oldest evicted at capacity |
OnAllowed |
func(*http.Request) |
nil |
Callback when a request passes |
OnRejected |
func(*http.Request, string) |
nil |
Callback when rejected (receives retryAfter) |
RejectionHandler |
func(w, r, retryAfter) |
nil (429 + Retry-After) |
Custom handler for rejected requests |
| Field | Type | Default | Description |
|---|---|---|---|
CookieName |
string |
"csrf_token" |
Name of the CSRF cookie |
HeaderName |
string |
"X-Csrf-Token" |
Request header containing the CSRF token (canonical MIME spelling; header names are case-insensitive on the wire) |
FieldName |
string |
"csrf_token" |
Form field name for the CSRF token |
MaxAge |
time.Duration |
24h |
Cookie max age |
Secure |
bool |
false |
Sets the Secure flag on the cookie (set true in production; with SameSite=None the constructor falls back to Secure=true) |
SameSite |
http.SameSite |
SameSiteLaxMode |
SameSite attribute on the cookie |
Domain |
string |
"" (host-only) |
Cookie domain |
Path |
string |
"/" |
Cookie path |
TrustedOrigins |
[]string |
nil |
Origins allowed for cross-domain CSRF (each must be a scheme://host origin; parsing is all-or-nothing — one bad entry means no trusted origins) |
TrustedProxies |
[]string |
nil |
IP/CIDR of reverse proxies that may strip origin headers |
AllowPlaintextBypass |
bool |
false |
Allow plaintext-HTTP origin bypass for all non-TLS requests (insecure) |
BootLogUntrustedForwardedProto |
bool |
false |
Opt-in boot warning when TrustedProxies is empty (XFP trust path silently inactive behind a TLS-terminating proxy) |
AllowInsecureSameSiteNone |
bool |
false |
Keep SameSite=None without Secure instead of the Secure=true fallback (legacy clients only; browsers refuse to store the cookie) |
ErrorHandler |
ErrorHandler |
nil (403 + body) |
Custom handler for CSRF validation failures |
When SameSite is None and Secure is false, CSRFMiddleware (and InvalidateCSRFCookie) fall back to Secure=true at construction instead of honoring the combination verbatim: rfc6265bis §5.7 makes such cookies unstorable in current browsers, so the previously-honored misconfiguration meant every state-changing browser request failed CSRF validation. The fallback keeps the None intent (cross-site cookies still work on HTTPS deployments), logs csrf_samesite_insecure, and is skipped verbatim with AllowInsecureSameSiteNone: true for legacy-client deployments. ConfigureNosurfHandler applies no fallback — it is the verbatim low-level configurator.
| Field | Type | Default | Description |
|---|---|---|---|
Recorder |
MetricsRecorder |
nil |
Receives one observation per request (required) |
PathFunc |
func(*http.Request) string |
nil (r.URL.Path) |
Extracts the path to record from the request |
| Field | Type | Default | Description |
|---|---|---|---|
ContentTypeNosniff |
bool |
true |
Set X-Content-Type-Options: nosniff |
FrameOptions |
string |
"DENY" |
X-Frame-Options value (empty = header omitted) |
StrictTransportSecurity |
string |
"" |
Strict-Transport-Security value (empty = omitted) |
ReferrerPolicy |
string |
"strict-origin-when-cross-origin" |
Referrer-Policy value (empty = omitted) |
ContentSecurityPolicy |
string |
"" |
Content-Security-Policy value (empty = omitted) |
| Field | Type | Default | Description |
|---|---|---|---|
ResponseHeader |
string |
"X-Request-ID" |
Response header to set with the resolved request ID |
GenerateID |
func() string |
Time-ordered hex | ID generator invoked when no incoming header is present |
IncomingHeader |
string |
"X-Request-ID" |
Incoming request header to read the upstream ID from |
| Field | Type | Default | Description |
|---|---|---|---|
Addr |
string |
":8080" |
Listen address |
ReadTimeout |
time.Duration |
10s |
Maximum duration for reading the entire request |
ReadHeaderTimeout |
time.Duration |
5s |
Maximum duration for reading request headers |
WriteTimeout |
time.Duration |
30s |
Maximum duration before timing out writes |
IdleTimeout |
time.Duration |
60s |
Maximum time to wait for the next request on a connection |
Every error this package produces is classified via go-error-family: it carries a machine-readable code (cors.max_age_negative), a behavioral family (Rejection, Transient, ...), and factual context (the offending field value). Three questions cover every consumer need:
// 1. Which component failed? Match by domain (the code's prefix).
if httputil.InDomain(err, httputil.Domain("cors")) {
// a CORS misconfiguration, not a network failure
}
// Route a CSRF rejection by its exact code — e.g. a 403 carrying
// csrf.origin_attestation_conflict means a client sent a forged
// Sec-Fetch-Site: same-origin attestation next to a cross-origin Origin.
// That is forged client input (Rejection family): fix or block the client,
// never retry.
if httputil.InDomain(err, httputil.Domain("csrf")) {
if coded, ok := errors.AsType[errorfamily.Coded](err); ok &&
coded.ErrorCode() == "csrf.origin_attestation_conflict" {
log.Printf("forged CSRF attestation from %s", r.RemoteAddr)
}
}
// 2. What is the exact failure? Read the code via the Coded interface.
if coded, ok := errors.AsType[errorfamily.Coded](err); ok {
log.Printf("code=%s", coded.ErrorCode()) // e.g. "server.timeout_ordering"
}
// 3. Should I retry? Ask the family.
if errorfamily.IsRetryable(err) { backoffAndRetry(err) }Conventions:
- Codes are hierarchical:
domain.specific_failure(e.g.compression.qvalue_too_large). Config-validation codes areRejection(fix the config, never retry); runtime write/hijack failures areTransient; shutdown and pool-contract violations areInfrastructure. - Sentinels still work:
errors.Is(err, ErrCSRFInvalid)matches by code and family, including through wrapped chains. - Message templates: call
httputil.RegisterErrorClassifications()once at startup to register user-facingwhat/why/fix/escapetemplates for every code; retrieve them witherrorfamily.TemplateForCode. - Constructor logging: middleware constructors log invalid configs (validate-and-log, not abort) with structured
code,family, anddomainfields, so log pipelines can route misconfigurations without parsing messages.
- Stdlib-first — all middleware uses
func(http.Handler) http.Handler, compatible with any Go HTTP framework - Round-trip fuzz invariants — every response-transforming middleware ships a decode-and-compare fuzz invariant (e.g.
FuzzCompressiongunzips every negotiated-gzip response and compares bytes); the invariant caught a real exact-fill duplication bug that 96.9% line coverage had missed. The nightly fuzz workflow runs all 30 of the repo's fuzz targets at 5 minutes each in CI. - Classified errors —
ResponseRecordererrors carry behavioral families (Transient, Infrastructure) and structured context via go-error-family for observability and retry logic - Minimal dependencies —
go-error-family(same author, zero transitive deps),go-etag(same author, ETag conditional requests),golang.org/x/time(canonical Go rate-limit extension), andjustinas/nosurf(CSRF protection).
Chain applies middleware in reverse declaration order so the first argument is outermost:
handler := Chain(mux,
Logging(logger), // outermost — logs first, sees final status
Recovery(logger), // catches panics before they reach Logging
Timeout(30*time.Second), // enforces deadline
RequestID(DefaultRequestIDConfig()),
SecurityHeaders(DefaultSecurityHeadersConfig()),
Nonce(DefaultNonceConfig()), // after SecurityHeaders so nonce CSP wins
CORS(DefaultCORSConfig()),
)Ordering at a glance (outermost first):
flowchart LR
A[Logging] --> B[Recovery] --> C[Timeout] --> D[RequestID] --> E[SecurityHeaders]
E --> F[Nonce] --> G[CORS] --> H[Decompression] --> I[MaxBodySize] --> J[Compression] --> K[ETag] --> M[Mux]
Decompression should be placed outer so downstream middleware (e.g., MaxBodySize) sees the decompressed body size:
// Decompression outer: handlers see decompressed bytes.
// MaxBodySize limits the decompressed size, not the compressed size.
handler := Chain(mux, Decompression(cfg), MaxBodySize(1<<20))ETag must be placed inside (after) Compression so it hashes the uncompressed body. If placed before Compression, the ETag would be computed over the compressed bytes — different content encodings on the wire would yield different ETags for the same logical resource, defeating conditional caching:
// Compression outer, ETag inner: ETag hashes the body the handler produced,
// not the bytes that left over the wire. All clients see the same ETag value
// regardless of which Accept-Encoding they negotiated.
handler := Chain(mux, Compression(cfg), etag.New(etag.DefaultETagConfig()))The default configuration supports gzip and deflate. Brotli, zstd, and other modern encodings are supported via the WriterFactory plugin interface without adding dependencies to the core library.
cfg := httputil.DefaultCompressionConfig()
cfg.WriterFactories["zstd"] = func(dst io.Writer) (io.WriteCloser, error) {
return zstd.NewWriter(dst)
}
handler := httputil.Compression(cfg)(mux)This keeps the core package dependency-free while allowing you to plug in any encoder that implements io.WriteCloser.
For a complete guide including pool reuse and brotli/zstd patterns, see the compression extensibility example.
httputil composes cleanly with declarative API frameworks like Huma, which generates OpenAPI 3.1 + JSON Schema from Go types but deliberately ships no middleware. Both target the same Go 1.22+ http.ServeMux via the humago adapter — no third-party router required:
router := http.NewServeMux()
api := humago.New(router, huma.DefaultConfig("My API", "1.0.0"))
huma.Get(api, "/things/{id}", getThing) // huma: types, validation, OpenAPI
handler := httputil.Chain(router, // httputil: compression, security, logging
httputil.Recovery(slog.Default()),
httputil.Logging(slog.Default()),
httputil.Compression(httputil.DefaultCompressionConfig()),
httputil.SecurityHeaders(httputil.DefaultSecurityHeadersConfig()),
httputil.CORS(httputil.DefaultCORSConfig()),
)See the full integration example and the detailed comparison.
For dependency injection and graceful lifecycle management, pair httputil with samber/do — httputil.Server.Shutdown(context.Context) error satisfies do.ShutdownerWithContextAndError structurally, so the container discovers and shuts down the HTTP server automatically. See the composition-root example.
For observability, see the Prometheus MetricsRecorder example. To build a secure middleware baseline once and apply it to many handlers (or nest whole stacks), see Composing Middleware Bundles.
This project maintains strict quality standards enforced in CI:
| Gate | Command | Status |
|---|---|---|
| Tests | go test -race -count=1 ./... |
Passing |
| Race stress | go test -race -count=10 ./... |
Passing |
| Coverage | go test -coverprofile=coverage.out ./... |
98.7% httputil / 98.4% httpspec (threshold: 95%) |
| Lint | golangci-lint run (~70 linters) |
0 issues |
| Vet | go vet ./... |
Clean |
| Vulnerabilities | govulncheck ./... |
None found |
| Nix flake | nix flake check |
All checks passed |
| Module integrity | go mod verify |
All modules verified |
go test ./... # Run tests
go test -race ./... # Race detection (REQUIRED for t.Parallel())
go test -race -count=10 ./... # Stress race detection
go vet ./... # Vet
go test -bench=. ./... # Benchmarks
golangci-lint run # Lint (~70 linters)
golangci-lint fmt # Format (gofumpt + golines@120 + gci)
govulncheck ./... # Vulnerability scan
nix flake check # Nix flake validationMIT — see LICENSE. The server_timing and endpoints sub-modules are MIT-licensed as well, each with its own LICENSE/LICENSE and README/README.