Applying Middleware & Ordering
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 on Rust 1.99 (edition 2024) with tower 0.5.3 and tower-http 0.7.1, checked on 2026-10-06. The previous page, Writing Middleware, showed how to write a middleware; this one shows how to attach it. Two decisions matter: which requests a middleware sees (all, matched, one route), and in which order middlewares wrap each other.
Where it runs in the request
In the request lifecycle (tokio accepts
the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and
extractors run, the handler runs, the response travels back out), this page decides where in the chain each layer
sits: around the whole Router (before routing, for URI rewriting and layers that must see every request), with
Router::layer (after routing, around every route and the fallback) or with route_layer and MethodRouter::layer
(after routing, only for matched routes or one method). In each place the layers form an onion: the outermost sees the
request first and the response last.
Three places to apply a layer
| Method | Wraps | Typical use |
|---|---|---|
|
every route added before the call, and the fallback |
tracing, request IDs, timeouts, compression, CORS — concerns that apply to every request, including `404`s |
|
only routes added before the call; runs only when a route matched |
authentication and
authorisation guards: an unauthenticated request to a non-existent path still gets |
|
one route’s handlers |
a per-endpoint body limit, timeout or rate limit |
Source: axum: Router::layer and Router::route_layer
Order of calls matters: layer only wraps routes that already exist. Routes added after .layer(...) are not
wrapped — a common source of "my auth middleware does not run" bugs.
use axum::{
Router,
extract::Request,
http::StatusCode,
middleware::{self, Next},
response::Response,
routing::get,
};
async fn require_token(req: Request, next: Next) -> Result<Response, StatusCode> {
match req.headers().get("authorization") {
Some(v) if v == "Bearer demo-token-not-a-secret" => Ok(next.run(req).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}
fn app() -> Router {
Router::new()
.route("/api/v1/me", get(|| async { "you\n" }))
.route_layer(middleware::from_fn(require_token)) // guards the routes above, matched requests only
.route("/health", get(|| async { "ok\n" })) // added after: not guarded
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum: Applying middleware
$ curl -si localhost:3000/api/v1/me | head -1
HTTP/1.1 401 Unauthorized
$ curl -si localhost:3000/health | head -1
HTTP/1.1 200 OK
$ curl -si localhost:3000/nope | head -1
HTTP/1.1 404 Not Found
Source: axum: Applying middleware
With .layer(...) instead of .route_layer(...), the last request would get 401, leaking nothing but confusing
clients and monitoring. Note that a route_layer with a guard that rejects also prevents axum’s automatic 405
for wrong methods on guarded paths, since the guard runs first.
Ordering: the onion
Each .layer() call wraps everything added so far, so the last call is the outermost layer: chained calls run
bottom to top on the request. tower::ServiceBuilder composes layers the other way round — the first layer added
to the builder is the outermost — so a builder reads top to bottom in request order. The axum documentation
recommends ServiceBuilder for that reason.
This program prints the order in which three named middlewares see the request and the response:
use axum::{
Router,
extract::Request,
middleware::{self, Next},
response::Response,
routing::get,
};
use tower::ServiceBuilder;
async fn tag(name: &'static str, req: Request, next: Next) -> Response {
println!("{name}: request");
let res = next.run(req).await;
println!("{name}: response");
res
}
async fn a(req: Request, next: Next) -> Response { tag("A", req, next).await }
async fn b(req: Request, next: Next) -> Response { tag("B", req, next).await }
async fn c(req: Request, next: Next) -> Response { tag("C", req, next).await }
#[tokio::main]
async fn main() {
use tower::ServiceExt;
let handler = get(|| async {
println!("handler");
"ok"
});
let chained: Router = Router::new()
.route("/", handler.clone())
.layer(middleware::from_fn(a))
.layer(middleware::from_fn(b))
.layer(middleware::from_fn(c));
let built: Router = Router::new().route("/", handler).layer(
ServiceBuilder::new()
.layer(middleware::from_fn(c))
.layer(middleware::from_fn(b))
.layer(middleware::from_fn(a)),
);
for (label, app) in [("chained .layer(A).layer(B).layer(C)", chained), ("ServiceBuilder C, B, A", built)] {
println!("--- {label}");
let req = Request::get("/").body(axum::body::Body::empty()).unwrap();
app.oneshot(req).await.unwrap();
}
}
Source: axum: Ordering
--- chained .layer(A).layer(B).layer(C)
C: request
B: request
A: request
handler
A: response
B: response
C: response
--- ServiceBuilder C, B, A
C: request
B: request
A: request
handler
A: response
B: response
C: response
Source: axum: Ordering
A ServiceBuilder is also a single value, so a whole stack can be built in one function and reused for several
routers (for example the public API and the admin API).
Layers, fallbacks and nested routers
-
Router::layerwraps the fallback too, so aTraceLayertraces404`s. `route_layerdoes not. -
A layer applied to a router before it is nested runs only for the nested routes; a layer applied to the parent after nesting runs for everything, outside the nested router’s own layers.
-
Middleware that must run before routing — URI rewriting,
NormalizePathLayer— cannot be added withRouter::layer; wrap the router from outside (see Routing). -
Each layer is cloned per connection or request, so layers holding state must share it through
Arc.
Where HandleErrorLayer goes
A fallible tower middleware (tower::timeout, load_shed, buffer) must have a HandleErrorLayer outside it. With
ServiceBuilder that means above it:
use std::time::Duration;
use axum::{BoxError, Router, error_handling::HandleErrorLayer, http::StatusCode, routing::get};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
async fn on_error(err: BoxError) -> StatusCode {
if err.is::<tower::timeout::error::Elapsed>() { StatusCode::REQUEST_TIMEOUT } else { StatusCode::SERVICE_UNAVAILABLE }
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "ok" })).layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http()) // infallible: may go anywhere
.layer(HandleErrorLayer::new(on_error)) // converts errors from everything below
.load_shed() // fallible
.concurrency_limit(100)
.timeout(Duration::from_secs(10)), // fallible
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app.into_make_service()).await.unwrap(); // build the limit once, not per connection
}
If HandleErrorLayer is placed below a fallible layer, the stack’s error type is BoxError again and Router::layer
rejects it at compile time with an unsatisfied Infallible bound. Back-pressure-based layers (load_shed,
concurrency_limit) only work around the whole router, as explained on
Tower Service Model.
The Bookshelf stack
Bookshelf applies its global middleware with one ServiceBuilder, outermost first. Yoon (Beginning Axum,
chapter 4) uses a similar order; the reasons are what matter:
use std::time::Duration;
use axum::{
Router,
extract::Request,
http::{HeaderName, Method, StatusCode},
middleware::{self, Next},
response::Response,
routing::get,
};
use tower::ServiceBuilder;
use tower_http::{
compression::CompressionLayer,
cors::CorsLayer,
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
sensitive_headers::SetSensitiveRequestHeadersLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
const REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
async fn require_auth(req: Request, next: Next) -> Result<Response, StatusCode> {
if req.headers().contains_key("authorization") { Ok(next.run(req).await) } else { Err(StatusCode::UNAUTHORIZED) }
}
fn app() -> Router {
let api = Router::new()
.route("/me", get(|| async { "me\n" }))
.route_layer(middleware::from_fn(require_auth)); // 8. auth: innermost, matched routes only
Router::new().nest("/api/v1", api).route("/", get(|| async { "home\n" })).layer(
ServiceBuilder::new()
.layer(SetSensitiveRequestHeadersLayer::new([axum::http::header::AUTHORIZATION, axum::http::header::COOKIE])) // 1.
.layer(SetRequestIdLayer::new(REQUEST_ID, MakeRequestUuid)) // 2. assign an ID before anything logs
.layer(TraceLayer::new_for_http()) // 3. one span per request, sees the final status
.layer(PropagateRequestIdLayer::new(REQUEST_ID)) // 4. copy the ID to the response
.layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10))) // 5.
.layer(CompressionLayer::new()) // 6. compress what the handler produced
// 7. CORS
.layer(CorsLayer::new().allow_methods([Method::GET, Method::POST]).allow_origin(["https://bookshelf.example".parse().unwrap()])),
)
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum example: request-id and tower-http
-
Sensitive headers first, so no later layer can log
AuthorizationorCookievalues. -
Request ID next, so the trace span and every log line can include it.
-
Trace outside timeout and compression, so the span covers the whole request and records the final status — including the
408produced by the timeout. -
Propagate the request ID to the response for client-side correlation.
-
Timeout outside the application layers, so slow handlers and slow body reads are both cut off.
-
Compression inside the timeout and trace, wrapping exactly the bytes the handler produces.
-
CORS close to the routes but outside authentication, so browser preflight
OPTIONSrequests are answered without credentials (preflights never carry them). -
Authentication innermost, with
route_layer, so unknown paths stay404.
Rate limiting, when used, goes near the top (after the request ID and trace) so rejected requests are cheap but still traced; see Rate Limiting & Load Shedding.
A complete stack on a small service
The official key-value-store example is a compact reference for a realistic stack: a shared HashMap behind an
RwLock, load shedding, a concurrency limit, a timeout with HandleErrorLayer, tracing, compression, a body limit on
the write route and an admin sub-router protected by a fixed bearer token. Reading it alongside this
page shows every placement rule in about 150 lines.
Source: axum example: key-value-store
The example uses tower-http’s ValidateRequestHeaderLayer::bearer, which is deprecated since tower-http 0.6.7
because a single fixed token is too basic for real applications; user authentication belongs in a session or JWT extractor (see JWT & Bearer
Tokens).
Where the books are outdated
Yoon (Beginning Axum, chapter 4) builds its stack with tower-http 0.6 and TimeoutLayer::new(duration), which has
been deprecated since tower-http 0.6.7 in favour of TimeoutLayer::with_status_code(status, duration); tower-http 0.7
keeps the deprecated constructor but new code should state the status explicitly. Its ordering advice is otherwise
current.