How to use middleware in Axum

Apply middleware in Axum using Router::layer for global effects or route_layer for specific paths.

The invisible traffic cop

You are building an API that handles user authentication, request logging, and CORS headers. Every single endpoint needs the same three checks before it touches your business logic. Copying that boilerplate into each handler creates a maintenance trap. You change one validation rule and suddenly you have to update twelve files. You need a way to intercept traffic, inspect it, modify it, or reject it before it reaches your route handlers. That is what middleware exists for.

What middleware actually is

Middleware sits between the raw HTTP request and your route handlers. Think of it like a series of checkpoints at a secure facility. The visitor walks through a metal detector, then a bag scanner, then a badge reader. Each station can inspect the visitor, attach a temporary pass, or turn them away. If they pass, they move to the next station. If they fail, the chain stops and sends them back to the entrance.

In Axum, this chain is built on the tower crate. Tower treats middleware as layers you stack around your routes. Each layer wraps the next one. When a request arrives, it peels through the layers from the outside in. When the response leaves, it travels back out through the same layers in reverse order. This design lets you compose small, focused pieces of logic without coupling them to your business code.

The minimal setup

Axum provides Router::layer to attach middleware globally and route_layer to attach it to specific paths. The most common way to write a custom middleware is with axum::middleware::from_fn. This function wraps a standard async function into a tower-compatible layer.

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

/// Intercepts every request before it reaches the handler.
async fn my_middleware<B>(
    req: Request,
    next: Next<B>,
) -> Response {
    // Inspect or modify the incoming request here.
    let modified_req = req;

    // Pass control to the next layer or the final handler.
    let mut res = next.run(modified_req).await;

    // Inspect or modify the outgoing response here.
    res
}

#[tokio::main]
async fn main() {
    // Build the router and attach the middleware to all routes.
    let app = Router::new()
        .route("/", get(|| async { "Hello" }))
        .layer(axum::middleware::from_fn(my_middleware));

    // Bind to a port and start serving traffic.
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app.into_make_service()).await.unwrap();
}

The signature looks heavy at first glance. Request is the incoming HTTP message. Next<B> is the bridge to the rest of the stack. Response is what you hand back to the client. The generic B represents the request body type, which Axum leaves flexible so you can swap in different body implementations later.

Apply from_fn to any async function that matches this signature. The compiler will handle the tower conversion automatically.

Walking through the request lifecycle

When a client sends a request, Axum matches the path against your router. Before calling the matched handler, it runs the middleware stack. Your function receives the Request and a Next<B> instance. Calling next.run(req).await hands the request to the next layer. If there are no more layers, it calls your route handler.

You can short-circuit the chain at any point. If a request lacks an API key, return a 401 Unauthorized response immediately. Skip next.run. The handler never executes. This pattern keeps unauthorized traffic from wasting CPU cycles on business logic.

You can also modify the request before passing it down. Attach a user ID to the request extensions, swap the body for a parsed JSON object, or inject a tracing span. The downstream handler receives your modified version. When the response bubbles back up, you can attach CORS headers, measure latency, or compress the payload.

Convention aside: the Axum community almost always uses axum::middleware::from_fn for application-level middleware. Implementing tower::Layer or tower::Service manually is reserved for library authors building reusable middleware crates. Stick to from_fn unless you are publishing a crate.

A realistic logging and timing example

Global middleware is useful, but you often need finer control. Maybe you want to log requests for your public API but skip internal health checks. Or maybe you want to time only the heavy database routes. route_layer solves this by scoping middleware to specific paths.

use axum::{
    extract::Request,
    middleware::Next,
    response::Response,
    routing::{get, post},
    Router,
};
use std::time::Instant;

/// Measures how long each request takes to process.
async fn timing_middleware<B>(
    req: Request,
    next: Next<B>,
) -> Response {
    // Record the exact moment the request enters this layer.
    let start = Instant::now();

    // Forward the request down the stack.
    let res = next.run(req).await;

    // Calculate elapsed time and print a structured log line.
    let elapsed = start.elapsed();
    println!("[TIMING] {} {} took {:?}", req.method(), req.uri().path(), elapsed);

    // Return the unmodified response to the client.
    res
}

#[tokio::main]
async fn main() {
    // Apply timing only to the heavy database routes.
    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .route("/users", get(|| async { "user list" }).route_layer(axum::middleware::from_fn(timing_middleware)))
        .route("/reports", post(|| async { "generated" }).route_layer(axum::middleware::from_fn(timing_middleware)));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app.into_make_service()).await.unwrap();
}

Notice how route_layer attaches directly to the route builder. The middleware only runs when that specific path matches. The /health endpoint bypasses it entirely. This keeps your metrics clean and your logs focused on the routes that actually matter.

Stack multiple layers by chaining .route_layer() calls. Axum composes them left to right. The first layer you attach runs first. The last layer runs closest to the handler. Order matters when layers depend on each other, like authentication running before authorization.

Common pitfalls and compiler friction

Middleware introduces a few friction points that trip up new Axum developers. The compiler will catch most of them, but the error messages can feel dense until you know what to look for.

The first trap is body consumption. HTTP bodies are streams. Once you read the bytes, the stream is exhausted. If your middleware parses the body into JSON, the downstream handler cannot read it again. You must buffer the body and attach the parsed data to Request::extensions(), or use Axum's built-in extractors that handle buffering for you. Trying to read an already-consumed body will fail at runtime with an empty payload.

The second trap is type mismatches. Axum's Request type is generic over the body. If you try to return a String or a Vec<u8> from your middleware, the compiler rejects you with E0308 (mismatched types). Middleware must always return axum::response::Response. Wrap your data in axum::response::Json, axum::response::Html, or axum::response::IntoResponse to satisfy the trait bound.

The third trap is forgetting the Next<B> generic. If you write async fn middleware(req: Request, next: Next) -> Response, the compiler complains about a missing type parameter. The B is required because tower needs to know the body type flowing through the stack. You can leave it as B without constraining it. The compiler will infer it from the router configuration.

Convention aside: add #[axum::debug_handler] to your middleware functions during development. It tells the compiler to generate more readable error messages when type inference fails. Remove it in production if you want slightly faster compile times, though the difference is negligible for most projects.

Choosing the right scope

Middleware placement changes how your application behaves. Pick the scope that matches your goal.

Use Router::layer when you need global behavior that applies to every route. Use it for CORS headers, global request logging, or panic recovery. The layer wraps the entire router, so it runs before path matching even happens.

Use Router::route_layer when you need targeted behavior for a subset of paths. Use it for timing specific endpoints, applying rate limits to public APIs, or attaching authentication only to protected routes. The layer sits directly on the route builder, so it only activates when that path matches.

Use axum::middleware::from_fn when you are writing application-level interceptors. Use it for quick logging, header injection, or simple validation. It wraps your async function into a tower-compatible layer without requiring trait implementations.

Use tower::ServiceBuilder when you are composing multiple middleware layers into a single reusable unit. Use it when you want to stack timeout, retry, and compression logic together and apply the bundle to several routes at once.

Use axum::extract::Extension when you need to pass data from middleware to handlers. Use it to inject parsed user claims, database connections, or feature flags. The middleware attaches the value to the request extensions, and the handler extracts it with the Extension extractor.

Where to go next