Middleware has the standard signature, so any net/http middleware works with Vukem:
type Middleware = func(http.Handler) http.HandlerThree stacks#
| Stack | Registered with | Runs |
|---|---|---|
| Server | r.UseServer(mw...) | Every request, even when no route matches (404s, static files, CORS preflight) |
| Router | r.Use(mw...) | Only when a route matches |
| Route / group | g.Use(mw...), route.Use(mw...), resource.Use(mw...) | Only for the routes it is attached to |
r := router.New(services)
r.UseServer(
middleware.Recover(),
middleware.RequestID(),
middleware.Logger(app.Log),
middleware.SecureHeaders(middleware.SecureHeadersConfig{}),
middleware.MethodOverride(),
static,
)
r.Use(middleware.Timeout(30 * time.Second))
r.Group("/api", func(g *router.Group) {
g.Use(middleware.JSONOnly(), middleware.BodyLimit(1<<20))
g.APIResource("posts", posts)
})The order is outermost first: server stack, then router stack, then each enclosing group from the outside in, then the route's own middleware.
Writing middleware#
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAdmin(r) {
web.Fail(w, r, errs.ErrForbidden) // rendered by the error handler
return
}
next.ServeHTTP(w, r)
})
}web.Fail(w, r, err) renders an error with the same format as handlers (HTML page, htmx fragment or problem+json). web.SecurityEvent(r, "authz.denied", ...) logs a security event at WARN with a stable event attribute. web.AuditEvent(r, "payout.recorded", ...) logs a routine audit record at INFO with the same envelope.
Built-in middleware#
| Middleware | Purpose |
|---|---|
Recover() | Converts panics to 500 through the error handler; the stack trace is logged server-side only. http.ErrAbortHandler is re-raised. |
RequestID() | Reuses a well-formed incoming X-Request-ID (max 128 safe characters) or generates one; stores it in the context and echoes it in the response. |
Logger(log) | One structured slog line per request: method, path, status, duration, bytes, request id, peer (the RemoteAddr host) and ip (the client resolved through TRUSTED_PROXIES, as c.IP()). 5xx at ERROR, 4xx at WARN. |
Timeout(d) | Sets a deadline on the request context. |
BodyLimit(n) | Caps request bodies at n bytes; larger bodies fail with 413. |
MethodOverride() | Lets HTML forms send PUT, PATCH and DELETE via a hidden _method field on POST. Register it as server middleware so routing sees the new method. |
JSONOnly() | Write requests must send JSON (415 otherwise) and errors render as problem+json. |
CORS(cfg) | Cross-origin resource sharing, deny by default. |
SecureHeaders(cfg) | Security headers and a nonce-based Content-Security-Policy. |
Static(root, cfg) / StaticFS(fsys, cfg) | Serves files from an *os.Root or any fs.FS. See Static files. |
Gzip() | Compresses text-like responses of 1 KiB or more for clients that accept gzip. |
CORS#
cors, err := middleware.CORS(middleware.CORSConfig{
AllowedOrigins: []string{"https://app.example.com"},
AllowCredentials: true,
MaxAge: 10 * time.Minute,
})
if err != nil {
return err // "*" with credentials is refused at boot
}
r.UseServer(cors)The zero CORSConfig allows no cross-origin requests. Defaults: methods GET, HEAD, POST, PUT, PATCH, DELETE; headers Accept, Authorization, Content-Type, X-Requested-With, X-CSRF-Token. Register it as server middleware so preflight requests are answered before routing.
Secure headers and CSP nonces#
r.UseServer(middleware.SecureHeaders(middleware.SecureHeadersConfig{
HSTSMaxAge: 365 * 24 * time.Hour,
TrustForwardedProto: true, // only behind a trusted proxy
TrustedProxies: proxies, // []*net.IPNet: honour X-Forwarded-Proto only from these peers
}))X-Forwarded-Proto counts only as a single header line saying https. SecureHeadersConfig.Validate() (called by DefaultMiddleware at boot) refuses TrustForwardedProto without TrustedProxies; without that check, an empty list trusts the header from any peer. DefaultMiddleware and APIMiddleware already install SecureHeaders; there, set TRUST_FORWARDED_PROTO=true and TRUSTED_PROXIES instead of adding a second one. When the proxy's range cannot be trusted, HSTSForce: true (HSTS_FORCE=true) sends HSTS on every response instead; see HSTS without a trusted range for the trade-offs.
| Header | Default |
|---|---|
Content-Security-Policy | middleware.DefaultCSP with a fresh nonce per request |
Strict-Transport-Security | max-age=63072000; includeSubDomains on HTTPS requests |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Cross-Origin-Opener-Policy | same-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=() |
CSP accepts a custom policy where {nonce} is replaced per request; "-" disables the header (same for FrameOptions). A negative HSTSMaxAge disables HSTS. Read the nonce with middleware.CSPNonce(r.Context()) and add it to inline scripts and styles:
nonce := middleware.CSPNonce(c.Context())
return c.HTML(http.StatusOK, `<script nonce="`+nonce+`">init()</script>`)CSPSources adds sources to the policy (the default or your CSP) without rewriting it. A fetch directive missing from the policy starts from the directive the browser would fall back to: script-src-elem from script-src (so it keeps the nonce), worker-src from child-src then script-src, frame-src from child-src, the others from default-src. ReportURI (an https:// URL without credentials, or a path starting with one /) adds report-uri and report-to plus the matching Reporting-Endpoints header:
cfg := middleware.SecureHeadersConfig{
CSPSources: map[string][]string{
"script-src": {"https://plausible.io"},
"connect-src": {"https://plausible.io"},
"frame-src": {"https://www.youtube-nocookie.com"}, // becomes 'self' https://www.youtube-nocookie.com
},
ReportURI: "/csp-reports",
}
if err := cfg.Validate(); err != nil {
return err
}Refused sources: 'unsafe-*' keywords, static nonces, * hosts (*, *:*, https://*), wildcards over fewer than two labels (*.com), host wildcards in default-src, script-src*, object-src, worker-src and base-uri, and unsafe schemes. Scheme-only sources are accepted only as https:, data: or blob: in img-src/media-src (https:/data: in font-src) and https: or wss: in connect-src.
SecureHeaders never fails: invalid extras are dropped (the policy stays stricter) with one warning in the log, so call Validate at boot (vukem.DefaultMiddlewareWith does). Anything weaker than the helper allows needs a full CSP string.
Styles injected at runtime need the nonce too, since style-src has no 'unsafe-inline'. The starters already handle the common cases:
- Inertia progress bar:
createInertiaApp({ nonce })reads it from<meta property="csp-nonce">(the scaffolds do this). - htmx 4 indicator: injected as a constructed stylesheet, which the CSP does not block. On htmx 2, set
htmx.config.inlineStyleNonceorincludeIndicatorStyles: false. - A library that injects a fixed
<style>and accepts no nonce: add its hash, e.g.CSPSources: {"style-src": {"'sha256-…'"}}(the browser console prints the hash it expects). style="…"attributes in server-rendered HTML stay blocked; use classes. Settingel.style.xfrom JavaScript is not affected.
Default stack#
vukem.DefaultMiddleware(app, r), called by the starter's routes.Web, installs:
| Stack | Middleware |
|---|---|
| Server | Recover, RequestID, Logger, SecureHeaders, MethodOverride |
| Router | BodyLimit(web.DefaultMaxBody), sessions, CSRF (CSRF_TRUSTED_ORIGINS adds trusted origins) |
vukem.DefaultMiddlewareWith(app, r, opts) installs the same stack with options; the zero vukem.MiddlewareOptions is DefaultMiddleware:
err := vukem.DefaultMiddlewareWith(app, r, vukem.MiddlewareOptions{
// Runs after the defaults (Vite dev server) are set; validated at boot.
SecureHeaders: func(cfg *middleware.SecureHeadersConfig) {
cfg.CSPSources = map[string][]string{
"script-src": {"https://plausible.io"},
"connect-src": {"https://plausible.io"},
}
cfg.ReportURI = "/csp-reports"
},
SessionBypass: []string{"/webhooks/"}, // no session (no cookie) on these routes
CSRFBypass: []string{"POST /webhooks/{name}"}, // signed webhooks; logged at boot
})| Option | Effect |
|---|---|
SecureHeaders | Edits the SecureHeadersConfig (CSPSources, ReportURI, TrustForwardedProto, ...). An invalid result fails at boot. |
Stateless | No session middleware at all, for cookieless apps. CSRF protection stays on as Origin/Sec-Fetch-Site checks only: csrfToken/csrfField render empty, and session features (c.Flash, session login) return an error. |
SessionBypass | ServeMux patterns served without sessions. A pattern ending in / (/webhooks/) covers its whole subtree; POST /hooks/{id} matches one method and path. Refused together with Stateless. |
CSRFBypass | ServeMux patterns (same matching) exempt from CSRF checks; each one is logged at boot. Protect them another way (signatures). |
I18n | *i18n.MiddlewareConfig for the locale resolver installed by WithTranslations (nil: defaults): sources and their order (Order), the selectable Locales, parameter and cookie names. Validated at boot; refused without WithTranslations. See Localization. |
Maintenance mode, the body limit and CSRF for every other route stay on: the options only add sources or skip what you list.
Generating middleware#
vukem make:middleware RequireAdmin # app/middleware/require_admin.goThe generated func RequireAdmin(next http.Handler) http.Handler is plain Go: attach it with r.Use, g.Use or route.Use. There is no named-middleware registry.