docs-site/content/docs/reference/middleware.md
+++ title = "Middleware catalog" description = "Every built-in middleware: config key, struct, default state, and knobs." date = 2026-07-03T00:00:00+00:00 updated = 2026-07-03T00:00:00+00:00 draft = false weight = 5 sort_by = "weight" template = "docs/page.html"
[extra] lead = "" toc = true top = false +++
Loco ships 13 built-in middlewares, all implementing the MiddlewareLayer
trait (src/controller/middleware/mod.rs:46-72). Each is configured under
server.middlewares.<key> in your environment YAML (src/config/server.rs:44)
and is optional (Option<T>) — omit the key entirely to get the framework's
own default; supply the key (even as {}) to take over its serde defaults
instead (see the callout below).
MiddlewareLayer traitpub trait MiddlewareLayer {
fn name(&self) -> &'static str;
fn is_enabled(&self) -> bool { true } // default
fn config(&self) -> serde_json::Result<serde_json::Value>;
fn apply(&self, app: AXRouter<AppContext>) -> Result<AXRouter<AppContext>>;
}
src/controller/middleware/mod.rs:46-72.
Config-key-present flips the default. For every middleware below whose "default enabled" is
true(catch_panic,etag,logger,request_id, and — outside Production —fallback), that default comes fromdefault_middleware_stack's own fallback value, used only when the key is absent from config (OptionisNone,src/controller/middleware/mod.rs:76-171). If you write the key at all — even as an empty mapping (etag: {}) — the struct's own#[serde(default)]takes over, which resolvesenabletofalseunless you setenable: trueexplicitly. In short: don't write a middleware's key in config unless you intend to also setenable.
default_middleware_stack(ctx) (mod.rs:76-171) returns middlewares as a
Vec in the coding order below (limit_payload → … → powered_by).
AppRoutes::to_router applies them in that same order, one app.layer(...)
call at a time (src/controller/app_routes.rs:305-309). Axum's
Router::layer wraps the existing router with each new layer as the
outer layer, so:
"the LAST middleware is the FIRST to meet the outside world (a user request starting), or 'LIFO' order" —
src/controller/app_routes.rs:283-286.
So an inbound request actually passes through the stack in the reverse
of the table order — powered_by first, limit_payload last (right before
the route handler) — and the response flows back out the opposite way.
Table order = coding/config order (default_middleware_stack, mod.rs:80-169).
limit_payloadlimit_payload · Struct: limit_payload::LimitPayload (limit_payload.rs:26)is_enabled() is hard-coded true (limit_payload.rs:70-72); there is no enable field. To turn it off, set body_limit: disable.DefaultBodyLimit.| Name | Type | Default |
|---|---|---|
body_limit | DefaultBodyLimitKind ("<size>" e.g. "5mb", or "disable") | 2mb (2,000,000 bytes) — limit_payload.rs:43-45 |
corscors · Struct: cors::Cors (cors.rs:19-42)| Name | Type | Default |
|---|---|---|
enable | bool | false |
allow_origins | Vec<String> | ["*"] |
allow_headers | Vec<String> | ["*"] |
allow_methods | Vec<String> | ["*"] |
expose_headers | Vec<String> | [] (empty) |
allow_credentials | bool | false |
max_age | Option<u64> (seconds) | None |
vary | Vec<String> | ["origin", "access-control-request-method", "access-control-request-headers"] |
The field is
expose_headers(plural) —cors.rs:32.
catch_paniccatch_panic · Struct: catch_panic::CatchPanic { enable } (catch_panic.rs:18-22)500 Internal Server Error instead of dropping the connection.| Name | Type | Default |
|---|---|---|
enable | bool | true (framework default when key absent) |
etagetag · Struct: etag::Etag { enable } (etag.rs:27-31)If-None-Match against the response ETag and returns 304 Not Modified on a match.| Name | Type | Default |
|---|---|---|
enable | bool | true (framework default when key absent) |
remote_ipremote_ip · Struct: remote_ip::RemoteIpMiddleware { enable, source } (remote_ip.rs)axum-client-ip crate.| Name | Type | Default |
|---|---|---|
enable | bool | false |
source | axum_client_ip::ClientIpSource | RightmostXForwardedFor |
source selects exactly one trusted source — there is no proxy-chain walking and no CIDR trust list. Valid values (serialized as the bare variant name, e.g. source: XRealIp): RightmostXForwardedFor (last value of the last X-Forwarded-For header, taken verbatim), RightmostForwarded (RFC 7239 Forwarded header), CfConnectingIp (Cloudflare), CloudFrontViewerAddress (AWS CloudFront), FlyClientIp (Fly.io), TrueClientIp (Akamai/Cloudflare), XEnvoyExternalAddress (Envoy/Istio), XRealIp (nginx), or ConnectInfo (the raw socket peer address, no header involved).
BREAKING (was
trusted_proxies: Option<Vec<String>>): the old middleware hand-rolledX-Forwarded-Forparsing, walking the header right-to-left and skipping any IP in a configurable trusted-proxy CIDR list (or a built-in RFC-1918 + loopback list) — i.e. it could see through a chain of one or more trusted proxies. The newsourcefield trusts exactly one hop and applies no CIDR filtering at all. If you run multiple hops (CDN → load balancer → ingress), configure your innermost hop to compute and set the correct client IP itself, and pointsourceat whatever header it writes (or pick a provider-specific source likeCfConnectingIp).
compressioncompression · Struct: compression::Compression { enable } (compression.rs:14-18)tower_http::compression::CompressionLayer).| Name | Type | Default |
|---|---|---|
enable | bool | false |
timeout_requesttimeout_request · Struct: timeout::TimeOut { enable, timeout } (timeout.rs:23-30)408 Request Timeout if it runs longer than timeout.| Name | Type | Default |
|---|---|---|
enable | bool | false |
timeout | u64 (milliseconds) | 5000 (timeout.rs:38-40) |
staticstatic (Rust field static_assets, #[serde(rename = "static")], mod.rs:197-199) · Struct: static_assets::StaticAssets (static_assets.rs:24-43)| Name | Type | Default |
|---|---|---|
enable | bool | false |
must_exist | bool | true |
folder.uri | String | "/static" |
folder.path | PathBuf | "assets/static" |
fallback | PathBuf | "assets/static/404.html" |
precompressed | bool | false (serves .gz variants when true) |
cache_control | Option<String> | None (e.g. "max-age=31536000") |
Under the
embedded_assetsfeature, this swaps at compile time forstatic_assets_embedded::StaticAssets(mod.rs:21-27) — same config key ("static") and knob surface, assets baked into the binary instead of read from disk.
secure_headerssecure_headers · Struct: secure_headers::SecureHeader { enable, preset, overrides } (secure_headers.rs:78-86)| Name | Type | Default |
|---|---|---|
enable | bool | false |
preset | String | "github" (secure_headers.rs:94-96) — other presets: owasp, empty (secure_headers.json) |
overrides | Option<BTreeMap<String, String>> | None |
loggerlogger · Struct: logger::Config { enable } → logger::Middleware via logger::new(config, &env) (logger.rs:21-25, 36-42)TraceLayer-based request logging (method, URI, version, user agent, request ID, environment).| Name | Type | Default |
|---|---|---|
enable | bool | true (framework default when key absent) |
request_idrequest_id · Struct: request_id::RequestId { enable } (request_id.rs:28-32)x-request-id header (sanitizes an incoming one or generates a UUID v4), and exposes it to handlers as LocoRequestId(String) via .get() (request_id.rs:63-72).| Name | Type | Default |
|---|---|---|
enable | bool | true (framework default when key absent) |
fallbackfallback · Struct: fallback::Fallback { enable, code, file, not_found } (fallback.rs:17-37); StatusCodeWrapper(pub StatusCode) (fallback.rs:15)environment != Production (mod.rs:158-167).fallback.html — instead of Axum's bare 404.| Name | Type | Default |
|---|---|---|
enable | bool | true outside Production, false in Production (framework default when key absent) |
code | StatusCode (as u16) | 200 (OK) — fallback.rs:39-41; set to 404 explicitly if that's what you want |
file | Option<String> | None — path to a file served as the fallback body |
not_found | Option<String> | None — a plain-text message served as the fallback body |
If neither file nor not_found is set, the bundled fallback.html is served.
powered_bymiddleware::Config; controlled by server.ident: Option<String> (src/config/server.rs:40). Struct: powered_by::Middleware via powered_by::new(ctx.config.server.ident.as_deref()) (powered_by.rs:27-58)Server-identifying header X-Powered-By: loco.rs.X-Powered-By response header.server.ident, not enable):server.ident value | Effect |
|---|---|
absent / None | X-Powered-By: loco.rs (default) |
"" (empty string) | middleware disabled — no header |
| any other string | X-Powered-By: <string> |
cargo loco middleware # list every middleware and its enabled state
cargo loco middleware --config # also print each middleware's JSON config
src/cli.rs:100-104, backed by list_middlewares (src/boot.rs:588-597), which calls each middleware's name(), is_enabled(), and config().