Middleware is code that runs around every request — before your handler
sees it and after it produces a response. It's where cross-cutting concerns
live: logging, rate limiting, security headers, CSRF, locale and timezone
resolution. Rustango ships a deep catalog of ready-made middleware and
makes writing your own a few lines. If you come from Django, this is the
MIDDLEWARE list; from Express, it's app.use(); from Laravel, it's the HTTP
kernel — same idea, attached to your router.
Source: middleware is built on
tower::Layer+axum::middleware::from_fn. Built-ins live acrossrustango::{access_log, rate_limit, cors, security_headers, request_id, …}, plusrustango::forms::csrf(thecsrffeature) andrustango::i18n(middleware::LocaleMiddleware,timezone). Most are on by default.Runnable version: every snippet below is copied from the tested example at
crates/rustango/examples/getting_started_blog/tests/middleware.rs(locale, timezone, security headers, CSRF — all DB-free). Run it withcargo test -p getting_started_blog --test middleware.
New to a term here? Middleware, layer, CSRF, locale — the glossary explains them in plain language.
- How middleware works in Rustango
- Ordering matters
- The built-in catalog
- Locale-aware middleware
- Timezone-aware middleware
- Security headers
- CSRF protection
- Writing your own middleware
- See also
There are two shapes, and you'll use both:
-
A
tower::Layer— a reusable, configurable middleware struct. Every built-in is one (SecurityHeadersLayer,RateLimitLayer, …). You attach a layer with axum's.layer(...), or — for most built-ins — with an extension-trait one-liner that reads better:use rustango::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt}; // These two are equivalent; the second is the ergonomic form. let app = router.layer(SecurityHeadersLayer::strict()); let app = router.security_headers(SecurityHeadersLayer::strict());
Each built-in module exports a
…RouterExttrait (SecurityHeadersRouterExt,RateLimitRouterExt, …). Bring it into scope and you get a.security_headers()/.rate_limit()/.cors()method directly onRouter. That's the idiomatic way to wire a stack:let app = router .request_id(RequestIdLayer::default()) .access_log(AccessLogLayer::default()) .rate_limit(RateLimitLayer::per_ip(60, Duration::from_secs(60))) .cors(CorsLayer::new().allow_origins(vec!["https://app.example.com"])) .security_headers(SecurityHeadersLayer::strict());
-
A function via
axum::middleware::from_fn— the quickest way to write a one-off. You get theRequestand aNext; you callnext.run(req)to continue, and you can do work on either side of that call. The timezone example below is exactly this.
Both compose freely — a from_fn middleware is a layer, so it stacks with
the built-ins in the same .layer(...) chain.
A layer wraps everything added before it, so the last layer you attach is the outermost — it runs first on the way in and last on the way out. Picture the stack as an onion; the request travels down to the handler and the response travels back up:
┌──────────────────────────────────────┐
request → │ security_headers (added last = top) │ → response
│ ┌────────────────────────────────┐ │
│ │ rate_limit │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ request_id (added first) │ │ │
│ │ │ → handler → │ │ │
│ │ └──────────────────────────┘ │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
Practical consequences:
- Put
request_id/access_lognear the bottom (added first) so the correlation id and the log span cover everything that runs after them. - Put rejection-cheap guards near the top (added last) —
allowed_hosts,rate_limit,body_limit— so a blocked request is dropped before it reaches the expensive inner layers. security_headersoutermost so the headers are stamped on every response, including ones short-circuited by an inner layer (a 429, a 403).
Every entry is a tower::Layer with a matching …RouterExt one-liner unless
noted. Bring the module's …RouterExt trait into scope to get the method.
| Concern | Layer | Wire it with |
|---|---|---|
| Security & access | ||
| Security headers (HSTS, XFO, CSP…) | SecurityHeadersLayer |
.security_headers(..) |
| CSRF (double-submit cookie) | CsrfLayer |
.layer(csrf::layer()) |
| CORS | CorsLayer |
.cors(..) |
| Host allow-list | AllowedHostsLayer |
.allowed_hosts(..) |
| IP allow/block | IpFilterLayer |
.ip_filter(..) |
| Force HTTPS | SslRedirectLayer |
.ssl_redirect(..) |
| CSP nonce per request | CspNonceLayer |
.csp_nonce(..) |
| HMAC request signing | HmacAuthLayer |
.layer(..) |
| Traffic & resilience | ||
| Rate limit (per-IP/global) | RateLimitLayer |
.rate_limit(..) |
| Distributed rate limit (cache-backed) | CacheRateLimitLayer |
.cache_rate_limit(..) |
| Request timeout | RequestTimeoutLayer |
.request_timeout(..) |
| Max body size | BodyLimitLayer |
.body_limit(..) |
| Maintenance mode (503) | MaintenanceLayer |
.maintenance(..) |
| Idempotency keys | IdempotencyLayer |
.idempotency(..) |
| Restrict HTTP methods | MethodRestrictLayer |
.require_get() / .require_post() / .require_safe() |
| Observability | ||
Request id (X-Request-Id) |
RequestIdLayer |
.request_id(..) |
| Access log (PII-redacted) | AccessLogLayer |
.access_log(..) |
tracing spans |
TracingLayer |
.layer(..) |
Server-Timing header |
ServerTimingLayer |
.server_timing(..) |
| Real client IP (behind proxies) | RealIpLayer |
.real_ip(..) |
| Content & response | ||
| gzip/br compression | CompressionLayer |
.compression(..) |
| ETag / conditional GET | EtagLayer |
.etag(..) |
| Trailing-slash redirect | TrailingSlashLayer |
.trailing_slash(..) |
| Page caching | CachePageLayer |
.layer(..) |
| Localization | ||
| Locale negotiation | LocaleMiddleware |
.layer(..) (+ ActiveLocale extractor) |
| Active timezone | (compose with from_fn) |
see below |
| Development | ||
| Live reload | LiveReloadLayer |
.livereload(..) |
| Debug panel | DebugPanelLayer |
.debug_panel(..) |
The next sections walk the ones the request asked for in detail.
LocaleMiddleware resolves one locale per request and injects it into the
request so any handler can read it. The pick order is Django's: cookie →
Accept-Language → default. The first locale you list is the default unless
you override it.
use rustango::i18n::middleware::{ActiveLocale, LocaleMiddleware};
// First entry is the default; `.default("en")` is explicit here. `ar`
// (Arabic) is included so we can prove the RTL convenience too.
let layer = LocaleMiddleware::new(&["en", "fr", "ar"]).default("en");
let app = Router::new()
.route(
"/",
// The extractor pulls the locale the layer chose for THIS request.
get(|loc: ActiveLocale| async move { format!("{} {}", loc.0, loc.direction()) }),
)
.layer(layer);Handlers read the result with the ActiveLocale extractor. It's
Infallible — if no middleware ran it falls back to "en" — and it carries
RTL helpers so templates can branch on bidirectionality:
loc.0 // the picked locale, e.g. "fr"
loc.direction() // "ltr" / "rtl" — feed straight into <html dir="…">
loc.is_rtl() // true for ar, he, fa, …The cookie name defaults to django_language (Django-compatible); change it
with .cookie_name("…"), or pass None to disable cookie lookup entirely.
The resolution precedence, verified end-to-end:
// Cookie says fr, Accept-Language says ar → the cookie wins.
// Cookie: django_language=fr ; Accept-Language: ar → "fr ltr"
//
// No cookie → negotiate Accept-Language (ar is RTL).
// Accept-Language: ar,en;q=0.5 → "ar rtl"
//
// Neither cookie nor a supported language → the default locale.
// (no headers) → "en ltr"For URL-prefix locales (/en/about, /fr/about) instead of header
negotiation, mount a sub-router per locale with Router::nest("/fr", …) and
inject the locale there.
There is deliberately no timezone layer. Instead the framework gives you a
task-local active offset (rustango::i18n::timezone) and a header/cookie
decoder, and you compose a one-line middleware that activates it — the
canonical "write your own" example. This mirrors Django's USE_TZ=True: store
UTC, render in the user's local clock.
use axum::middleware::{from_fn, Next};
use rustango::i18n::timezone::{current_offset, from_request_headers, with_offset};
/// Per-request middleware: decode the client's UTC offset from the
/// `tz_offset` cookie (or a `Time-Zone:` header), then run the rest of the
/// stack with that offset active so `current_offset()` / the `localtime`
/// Tera filter see it. Falls back to UTC when nothing parseable is sent.
async fn timezone_mw(req: Request<Body>, next: Next) -> Response {
match from_request_headers(req.headers(), "tz_offset") {
Some(offset) => with_offset(offset, next.run(req)).await,
None => next.run(req).await,
}
}
let app = Router::new()
.route(
"/now",
// The handler runs inside the activated scope, so the task-local
// offset is visible here (and inside any `.await` it makes).
get(|| async { current_offset().local_minus_utc().to_string() }),
)
.layer(from_fn(timezone_mw));with_offset installs the offset in a tokio::task_local, so it survives
.await points and thread hops within the request — unlike a thread-local, it
won't vanish when tokio reschedules the task. Inside the scope:
current_offset()returns the activeFixedOffset(UTC outside any scope),localtime(utc_dt)converts a stored UTCDateTimeto it,- the
{{ ts | localtime }}Tera filter (register withtimezone::register_filters) renders in it automatically.
from_request_headers accepts the tz_offset cookie first, then a
Time-Zone: (or X-Timezone:) header. The accepted formats are flexible —
"+05:30", "+0530", "Z"/"UTC", or signed integer minutes ("330",
"-300"), the shape JS's Date.getTimezoneOffset() produces. The cookie is
typically set by a tiny snippet on page load:
<script>
// getTimezoneOffset() is positive west-of-UTC, so flip the sign.
const minutes = -new Date().getTimezoneOffset();
document.cookie = `tz_offset=${minutes};path=/;max-age=31536000`;
</script>Behavior, verified:
// Cookie: tz_offset=330 (UTC+05:30) → current_offset() = +19800s
// Header: Time-Zone: -300 (UTC-05:00) → current_offset() = -18000s
// nothing sent → current_offset() = 0 (UTC)Note —
FixedOffset, not IANA. This models the active timezone as a fixed offset, which is everything a single point in time needs and avoids the ~3 MBchrono-tzdatabase. It does not handle DST transitions. For full IANA support, parse the user'sTzwithchrono-tzat request time and passtz.offset_from_utc_datetime(...)intowith_offset— the middleware shape above is unchanged.
SecurityHeadersLayer stamps the standard browser-hardening headers — HSTS,
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and an optional
Content-Security-Policy — onto every response, in one line.
use rustango::security_headers::{CspBuilder, SecurityHeadersLayer, SecurityHeadersRouterExt};
let app = Router::new()
.route("/", get(|| async { "ok" }))
.security_headers(SecurityHeadersLayer::strict().csp(CspBuilder::strict_starter().build()));That hardens every response:
// strict-transport-security: max-age=31536000; includeSubDomains; preload
// x-content-type-options: nosniff
// x-frame-options: DENY
// content-security-policy: <built from CspBuilder::strict_starter()>Three presets cover the common cases:
| Preset | Use for | HSTS | X-Frame-Options |
|---|---|---|---|
strict() |
production | 1 year + preload | DENY |
relaxed() |
embeddable pages | 1 year | SAMEORIGIN |
dev() |
local dev | (omitted) | — |
Use dev() locally — HSTS would otherwise pin your browser to HTTPS for a year
on localhost. Build a CSP fluently with CspBuilder (.default_src(..),
.script_src(..), …, .build()), and roll it out safely with
.csp_report_only(true) + .csp_report_uri("/csp-report") before enforcing.
CSRF (cross-site request forgery) is when another site tricks a logged-in
user's browser into submitting to your app. Rustango defends with the
double-submit-cookie pattern, in rustango::forms::csrf (behind the csrf
feature, turned on automatically by admin).
use rustango::forms::csrf;
let app = Router::new()
.route("/form", get(|| async { "render form" }))
.route("/submit", post(|| async { "accepted" }))
.layer(csrf::layer());A safe method (GET/HEAD/OPTIONS) mints a rustango_csrf cookie. An unsafe
method (POST/PUT/PATCH/DELETE) must echo that cookie's value back, either in the
X-CSRF-Token header or the _csrf form field; a mismatch is 403 Forbidden:
// GET /form → 200 + Set-Cookie: rustango_csrf=…
//
// POST /submit (no token) → 403 Forbidden
//
// POST /submit with both:
// Cookie: rustango_csrf=<t>
// X-CSRF-Token: <t> → 200 OK (double-submit matches)In Tera templates, {{ csrf_token }} gives the raw token and {{ csrf_input }}
a ready-made hidden <input name="_csrf"> — drop one in every form. Override
the cookie/header names or the Secure flag with csrf::with_config(CsrfConfig);
for SPA setups, add .with_trusted_origins([...]) to enable the Origin-header
defense-in-depth check on top of the token. For append-only collector endpoints
hit via navigator.sendBeacon (e.g. analytics) that can't send a token header,
CsrfConfig::exempt_prefix("/path") skips enforcement for a narrow path prefix.
The auto-admin enables CSRF on every mutation with no opt-out.
You saw the quick form already — the timezone middleware
is a complete from_fn example. Use from_fn whenever the logic is app-specific
and you don't need to configure it:
use axum::middleware::{from_fn, Next};
async fn add_app_version(req: Request<Body>, next: Next) -> Response {
let mut resp = next.run(req).await; // run the rest of the stack
resp.headers_mut()
.insert("X-App-Version", HeaderValue::from_static(env!("CARGO_PKG_VERSION")));
resp // …then touch the response
}
let app = router.layer(from_fn(add_app_version));Reach for a full tower::Layer when the middleware is reusable and
configurable — when you'd ship it as part of a library. The pattern is a small
Layer struct that builds a Service, plus (by convention) a …RouterExt
extension trait for the one-liner. rustango::request_id is the smallest
complete reference to copy:
RequestIdLayer— the configurable layer (::default(),.always_generate()),RequestIdService<S>— wraps the inner service; reads/sets theX-Request-Idheader insidecall,RequestId— aFromRequestPartsextractor so handlers can read the id,RequestIdRouterExt— givesRouter::request_id(layer).
LocaleMiddleware (read in crates/rustango/src/i18n/middleware.rs) is a second
worked example — same four pieces, plus a pure .pick(&req) method that's unit-
testable without spinning up a server. Model new layers on either.
- Security guide — the full defense-in-depth stack and a
deploy-time checklist (
manage check --deploy). - Authentication — auth backends and the
CurrentUsermiddleware build on the same layer mechanism. - URLs & routing — where layers attach to routers and sub-routers.
