Writing Middleware

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. axum has no middleware system of its own; middleware is any tower Layer (see Tower Service Model). Writing a Layer by hand is verbose, so axum offers adapters that turn async functions into layers. Choosing where to apply middleware and in which order is covered on Applying Middleware & Ordering, and ready-made layers on tower-http Catalog.

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), middleware written on this page runs between the router and the extractors when added with Router::layer or route_layer, and between hyper and the router when it wraps the whole Router as a tower layer. Either way it sees the request before the handler’s extractors and the response after IntoResponse, and it can short-circuit with its own response without calling the handler.

Which tool?

Tool Use when

middleware::from_fn / from_fn_with_state

the default: an async function that sees the request, may short-circuit, calls the rest of the stack and sees the response

middleware::map_request / map_response (and _with_state)

you only transform the request or the response

middleware::from_extractor

an existing extractor already encodes the check (authentication)

tower combinators (ServiceBuilder::map_request, …​)

trivial synchronous transformations

a hand-written Service + Layer

you are publishing reusable middleware, need per-connection state, back-pressure (poll_ready) or a configurable builder

from_fn

A from_fn middleware is an async function whose last two arguments are the Request and a Next; any number of extractors may come first (they must implement FromRequestParts). It returns something that implements IntoResponse:

use std::time::Instant;

use axum::{
    Router,
    extract::{MatchedPath, Request},
    http::{HeaderValue, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::get,
};

/// Adds `server-timing` and rejects requests without an Accept header.
async fn timing(matched: Option<MatchedPath>, req: Request, next: Next) -> Result<Response, StatusCode> {
    if !req.headers().contains_key("accept") {
        return Err(StatusCode::NOT_ACCEPTABLE); // short-circuit: the handler never runs
    }
    let start = Instant::now();
    let mut response = next.run(req).await; // call the rest of the stack
    let elapsed = start.elapsed().as_secs_f64() * 1000.0;
    let route = matched.as_ref().map_or("unmatched", MatchedPath::as_str);
    let value = format!("app;dur={elapsed:.2};desc=\"{route}\"");
    if let Ok(v) = HeaderValue::from_str(&value) {
        response.headers_mut().insert("server-timing", v);
    }
    Ok(response)
}

async fn books() -> impl IntoResponse {
    "books\n"
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books", get(books)).layer(middleware::from_fn(timing));
    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/books | grep server-timing
server-timing: app;dur=0.04;desc="/api/v1/books"
$ curl -si -H 'accept:' localhost:3000/api/v1/books | head -1
HTTP/1.1 406 Not Acceptable
  • next.run(req) returns a Response; there is no error to handle, because everything below is infallible.

  • Code before next.run sees the request, code after it sees the response. Not calling next.run short-circuits.

  • MatchedPath is Option because a Router::layer middleware also runs for unmatched requests (the fallback).

  • The middleware function must satisfy the same rules as a handler: Clone + Send + Sync + 'static, a Send future.

Accessing state: from_fn_with_state

use std::sync::Arc;

use axum::{
    Router,
    extract::{Request, State},
    http::StatusCode,
    middleware::{self, Next},
    response::Response,
    routing::get,
};

#[derive(Clone)]
struct AppState {
    maintenance: Arc<std::sync::atomic::AtomicBool>,
}

async fn maintenance_mode(State(state): State<AppState>, req: Request, next: Next) -> Result<Response, StatusCode> {
    if state.maintenance.load(std::sync::atomic::Ordering::Relaxed) && req.method() != "GET" {
        return Err(StatusCode::SERVICE_UNAVAILABLE); // read-only while maintenance runs
    }
    Ok(next.run(req).await)
}

#[tokio::main]
async fn main() {
    let state = AppState { maintenance: Arc::new(true.into()) };
    let app = Router::new()
        .route("/api/v1/books", get(|| async { "books\n" }).post(|| async { "created\n" }))
        .layer(middleware::from_fn_with_state(state.clone(), maintenance_mode))
        .with_state(state);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The state is passed twice: once to the layer (it is created before the router knows its state) and once to the router. Forgetting the first produces a "trait bound FromFn<...>: Service<...> is not satisfied" error; #[axum::debug_middleware] on the function turns that into a precise message, like #[debug_handler] does for handlers.

Passing data to handlers: request extensions

Middleware often computes something the handler needs — the authenticated user, a request ID, a tenant. Insert it into the request’s extensions (a type map) and extract it in the handler with Extension<T>:

use axum::{
    Extension, Router,
    extract::Request,
    http::StatusCode,
    middleware::{self, Next},
    response::Response,
    routing::get,
};

#[derive(Clone, Debug)]
struct Member {
    id: u64,
    name: String,
}

async fn authenticate(mut req: Request, next: Next) -> Result<Response, StatusCode> {
    let token = req
        .headers()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .ok_or(StatusCode::UNAUTHORIZED)?;
    // a real implementation verifies a session or JWT here
    let member = match token {
        "demo-token-not-a-secret" => Member { id: 42, name: "Octavia".into() },
        _ => return Err(StatusCode::UNAUTHORIZED),
    };
    req.extensions_mut().insert(member);
    Ok(next.run(req).await)
}

async fn my_loans(Extension(member): Extension<Member>) -> String {
    format!("loans of member {} ({})\n", member.id, member.name)
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/api/v1/me/loans", get(my_loans))
        .route_layer(middleware::from_fn(authenticate)); // only for matched routes
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

If a handler extracts Extension<Member> on a route the middleware does not cover, the request fails at runtime with a 500 ("Missing request extension"). A custom CurrentUser extractor (see Custom Extractors) avoids that failure mode and is how Bookshelf does it; extensions remain the right tool for data that only middleware can compute.

map_request, map_response and from_extractor

use axum::{
    Router,
    extract::{FromRequestParts, Request},
    http::{HeaderValue, StatusCode, request::Parts},
    middleware::{from_extractor, map_request, map_response},
    response::Response,
    routing::get,
};

async fn add_request_header(mut req: Request) -> Request {
    req.headers_mut().insert("x-bookshelf-edge", HeaderValue::from_static("eu-west"));
    req
}

async fn add_security_headers(mut res: Response) -> Response {
    res.headers_mut().insert("x-content-type-options", HeaderValue::from_static("nosniff"));
    res
}

/// An extractor used as a guard: requests without the API key never reach the handlers.
struct ApiKey;

impl<S: Send + Sync> FromRequestParts<S> for ApiKey {
    type Rejection = StatusCode;

    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
        let expected = std::env::var("BOOKSHELF_ADMIN_KEY").map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
        match parts.headers.get("x-api-key") {
            Some(k) if k.as_bytes() == expected.as_bytes() => Ok(ApiKey),
            _ => Err(StatusCode::UNAUTHORIZED),
        }
    }
}

#[tokio::main]
async fn main() {
    let admin = Router::new()
        .route("/reindex", get(|| async { "reindexing\n" }))
        .route_layer(from_extractor::<ApiKey>());
    let app = Router::new()
        .route("/", get(|req: Request| async move { format!("{:?}\n", req.headers().get("x-bookshelf-edge")) }))
        .nest("/admin", admin)
        .layer(map_request(add_request_header))
        .layer(map_response(add_security_headers));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

map_request functions may also return Result<Request, E> to reject, and both accept extractors before the request or response argument. Comparing secrets with == is fine for an admin demo; for real API keys, compare hashes or use a constant-time comparison (see Password Authentication).

Rewriting the request URI

Middleware added with Router::layer runs after routing, so changing the URI there does not change which route matches. To rewrite before routing, wrap the whole router — as with NormalizePathLayer on Routing:

use axum::{Router, ServiceExt, extract::Request, http::Uri, routing::get};
use tower::Layer;

fn rewrite_legacy_paths(mut req: Request) -> Request {
    // /catalog/... (old site) -> /catalogue/...
    if let Some(rest) = req.uri().path().strip_prefix("/catalog/") {
        let new = format!("/catalogue/{rest}");
        if let Ok(uri) = new.parse::<Uri>() {
            *req.uri_mut() = uri;
        }
    }
    req
}

#[tokio::main]
async fn main() {
    let router = Router::new().route("/catalogue/{shelf}", get(|uri: Uri| async move { format!("{uri}\n") }));
    let middleware = tower::util::MapRequestLayer::new(rewrite_legacy_paths);
    let app = middleware.layer(router);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await.unwrap();
}

This rewrite drops the query string; a production version keeps uri.query(). For SEO-relevant moves, a 308 redirect is usually better than a silent rewrite.

Reading and restoring the body

Middleware that must inspect the body — signature verification, audit logging — has to buffer it and put it back, because a body can be read only once. The official print-request-response example does it like this:

use axum::{
    Router,
    body::{Body, Bytes},
    extract::Request,
    http::StatusCode,
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::post,
};
use http_body_util::BodyExt;

async fn print_request_response(req: Request, next: Next) -> Result<impl IntoResponse, (StatusCode, String)> {
    let (parts, body) = req.into_parts();
    let bytes = buffer_and_print("request", body).await?;
    let req = Request::from_parts(parts, Body::from(bytes));

    let res = next.run(req).await;

    let (parts, body) = res.into_parts();
    let bytes = buffer_and_print("response", body).await?;
    Ok(Response::from_parts(parts, Body::from(bytes)))
}

async fn buffer_and_print<B>(direction: &str, body: B) -> Result<Bytes, (StatusCode, String)>
where
    B: axum::body::HttpBody<Data = Bytes>,
    B::Error: std::fmt::Display,
{
    let bytes = match body.collect().await {
        Ok(collected) => collected.to_bytes(),
        Err(err) => return Err((StatusCode::BAD_REQUEST, format!("failed to read {direction} body: {err}"))),
    };
    if let Ok(body) = std::str::from_utf8(&bytes) {
        tracing::debug!("{direction} body = {body:?}");
    }
    Ok(bytes)
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_env_filter("debug").init();
    let app = Router::new()
        .route("/api/v1/reviews", post(|body: String| async move { format!("stored {} bytes\n", body.len()) }))
        .layer(middleware::from_fn(print_request_response));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Buffering defeats streaming and costs memory, so apply such middleware with route_layer to the few routes that need it, and put a body limit in front (see Request Limits & Timeouts). The consume-body-in-extractor-or-middleware example shows the same technique inside an extractor. Never log bodies that may contain passwords or tokens.

A hand-written Service and Layer

For reusable middleware — something you would publish as a crate — implement tower’s traits directly. A Layer is a factory that wraps an inner service; the Service does the work. With Pin<Box<dyn Future>> as the future type the code stays readable:

use std::task::{Context, Poll};
use std::time::Duration;

use axum::{Router, extract::Request, response::Response, routing::get};
use futures::future::BoxFuture;
use tower::{Layer, Service};

/// Logs requests slower than a threshold.
#[derive(Clone)]
struct SlowRequestLayer {
    threshold: Duration,
}

impl<S> Layer<S> for SlowRequestLayer {
    type Service = SlowRequest<S>;

    fn layer(&self, inner: S) -> Self::Service {
        SlowRequest { inner, threshold: self.threshold }
    }
}

#[derive(Clone)]
struct SlowRequest<S> {
    inner: S,
    threshold: Duration,
}

impl<S> Service<Request> for SlowRequest<S>
where
    S: Service<Request, Response = Response> + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx) // propagate the inner service's readiness
    }

    fn call(&mut self, req: Request) -> Self::Future {
        let path = req.uri().path().to_owned();
        let threshold = self.threshold;
        let start = std::time::Instant::now();
        let future = self.inner.call(req);
        Box::pin(async move {
            let response = future.await?;
            if start.elapsed() > threshold {
                tracing::warn!(%path, elapsed_ms = start.elapsed().as_millis(), "slow request");
            }
            Ok(response)
        })
    }
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init();
    let app = Router::new()
        .route("/slow", get(|| async {
            tokio::time::sleep(Duration::from_millis(300)).await;
            "done\n"
        }))
        .layer(SlowRequestLayer { threshold: Duration::from_millis(100) });
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
  • poll_ready must forward to the inner service so back-pressure propagates.

  • call must not hold &mut self across the future: the inner future is created synchronously and moved into the boxed async block.

  • A subtle tower rule: poll_ready reserves capacity on this clone of the service. If call clones inner and calls the clone, use std::mem::replace to swap the ready service in, as the axum documentation explains; the version above calls self.inner directly, which is always correct.

  • For zero-allocation middleware, write a custom Future type with pin-project-lite instead of boxing; tower-http’s layers are built that way.

Where the books are outdated

Yoon (Beginning Axum, chapter 4) writes middleware with from_fn using the Next type without generics, which is the 0.7+ form (0.6 had Next<B> with a body type parameter, and Request<B> instead of axum::extract::Request). Code that still names Next<Body> or Request<Body> in middleware signatures must drop the parameter.