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

Router::layer(L)

every route added before the call, and the fallback

tracing, request IDs, timeouts, compression, CORS — concerns that apply to every request, including `404`s

Router::route_layer(L)

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 404, not 401

MethodRouter::layer(L) / route_layer(L)

one route’s handlers

a per-endpoint body limit, timeout or rate limit

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();
}
$ 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

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.

Two onion diagrams side by side. Left: Router::new() with .layer(A).layer(B).layer© chained; each call wraps everything above it, so C is the outermost layer, then B, then A around the routes and handlers; the request flows C, B, A, handler and the response A, B, C. Right: Router::new().layer(ServiceBuilder::new().layer©.layer(B).layer(A)); the builder reads like the request path, so C, first in the builder, is outermost, then B, then A, last in the builder, around the handlers; the onion is identical.

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::layer wraps the fallback too, so a TraceLayer traces 404`s. `route_layer does 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 with Router::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();
}
  1. Sensitive headers first, so no later layer can log Authorization or Cookie values.

  2. Request ID next, so the trace span and every log line can include it.

  3. Trace outside timeout and compression, so the span covers the whole request and records the final status — including the 408 produced by the timeout.

  4. Propagate the request ID to the response for client-side correlation.

  5. Timeout outside the application layers, so slow handlers and slow body reads are both cut off.

  6. Compression inside the timeout and trace, wrapping exactly the bytes the handler produces.

  7. CORS close to the routes but outside authentication, so browser preflight OPTIONS requests are answered without credentials (preflights never carry them).

  8. Authentication innermost, with route_layer, so unknown paths stay 404.

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.

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.