Guide contents

Lesson 14 of 30

Middleware with Tower and tower-http

Middleware wraps a service and runs code before and after it. Axum builds on Tower, so every Axum middleware is a Tower Layer. This lesson covers the two Tower traits, layer ordering, from_fn, and the tower-http layers for request ids, tracing, CORS, body limits, timeouts, and compression.

Term Meaning
Service Tower trait for an asynchronous function from a request to a response
Layer Tower trait that takes a service and returns a wrapped service
ServiceBuilder Stacks several layers in the order the request travels
Request id Unique identifier attached to a request so logs from all components can be joined

Project setup

Cargo.toml

TOML
[package]
name = "storefront-middleware"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["trace", "cors", "limit", "compression-gzip", "request-id", "propagate-header"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde_json = "1"

Each tower-http layer is behind a Cargo feature, so the list names the ones this lesson uses. The tower features util and timeout provide ServiceBuilder and the timeout layer.

Example 1: Layer ordering

Layers wrap the service like nested boxes. With .layer(...) chained on a Router, the last layer added is the outermost: it sees the request first and the response last. ServiceBuilder lists layers in request order, so the first one listed is the outermost.

The program below registers the same two layers both ways.

src/main.rs

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

async fn layer_a(request: Request, next: Next) -> Response {
    println!("A in");
    let response = next.run(request).await;
    println!("A out");
    response
}

async fn layer_b(request: Request, next: Next) -> Response {
    println!("B in");
    let response = next.run(request).await;
    println!("B out");
    response
}

async fn handler() -> &'static str {
    println!("handler");
    "ok"
}

#[tokio::main]
async fn main() {
    let chained = Router::new()
        .route("/chained", get(handler))
        .layer(from_fn(layer_a))
        .layer(from_fn(layer_b));

    let built = Router::new().route("/built", get(handler)).layer(
        ServiceBuilder::new()
            .layer(from_fn(layer_b))
            .layer(from_fn(layer_a)),
    );

    let app = chained.merge(built);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
Terminal
cargo run
curl -s -w '\n' localhost:3000/chained
curl -s -w '\n' localhost:3000/built
Output
ok
ok

The server prints this for each request:

Output
B in
A in
handler
A out
B out
  • Router::layer applies to the routes added before the call. Routes added later are not covered.
  • Router::route_layer applies only to matched routes, so an unknown path returns 404 without running the layer.

Placement rules that follow from the ordering:

Concern Placement Reason
Request id Outermost Every later layer and log line can use it
Tracing Inside request id Spans include the id and cover the inner layers
CORS Outside authentication Preflight OPTIONS requests carry no credentials
Timeout Outside authentication and handlers The limit covers slow lookups and slow handlers
Compression Inside tracing, outside handlers It compresses the final body

Example 2: A hand-written Layer and Service

A Service<Request> has Response, Error, and Future associated types and two methods. poll_ready reports whether the service can accept a request, which lets a service apply backpressure. call takes the request and returns the future for the response. A Layer is a factory: layer receives the inner service and returns the wrapper.

This middleware prints the method and path, then returns the inner future unchanged.

src/log_layer.rs

Rust
use axum::http::Request;
use std::task::{Context, Poll};
use tower::{Layer, Service};

#[derive(Clone)]
pub struct LogLayer;

impl<S> Layer<S> for LogLayer {
    type Service = LogService<S>;

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

#[derive(Clone)]
pub struct LogService<S> {
    inner: S,
}

impl<S, B> Service<Request<B>> for LogService<S>
where
    S: Service<Request<B>>,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = S::Future;

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

    fn call(&mut self, request: Request<B>) -> Self::Future {
        println!("{} {}", request.method(), request.uri().path());
        self.inner.call(request)
    }
}

Code that must run after the response needs its own future type, which is why most middleware uses from_fn (next example) or comes from tower-http. Implement Service and Layer directly when publishing a reusable layer or when the middleware must be generic over the inner service and error types.

Example 3: Middleware with from_fn

axum::middleware::from_fn turns an async function into a layer. The function receives the request and a Next value that calls the rest of the stack. Code before next.run(request) runs on the way in, and code after runs on the way out. Returning without calling next.run short-circuits the request.

src/middleware.rs

Rust
use crate::AppState;
use axum::{
    extract::{Request, State},
    http::{HeaderValue, StatusCode},
    middleware::Next,
    response::Response,
};

#[derive(Clone)]
pub struct RequestContext {
    pub request_id: String,
}

pub async fn require_api_key(
    State(state): State<AppState>,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let supplied = request.headers().get("x-api-key").and_then(|v| v.to_str().ok());
    if supplied != Some(state.api_key.as_str()) {
        return Err(StatusCode::UNAUTHORIZED);
    }
    Ok(next.run(request).await)
}

pub async fn attach_context(mut request: Request, next: Next) -> Response {
    let request_id = request
        .headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("none")
        .to_string();
    request.extensions_mut().insert(RequestContext { request_id });
    next.run(request).await
}

pub async fn add_server_header(request: Request, next: Next) -> Response {
    let mut response = next.run(request).await;
    response
        .headers_mut()
        .insert("x-served-by", HeaderValue::from_static("storefront"));
    response
}
  • require_api_key short-circuits with 401. It uses from_fn_with_state to read the application state.
  • attach_context stores a typed value in the request extensions, a map keyed by type. A handler reads it with Extension<RequestContext>. If the layer is missing, the extractor fails at request time with a 500, not at compile time.
  • add_server_header changes the response after the handler ran.

Use an extractor when a value can be computed from one request's parts, and an extension when a layer computes it.

Example 4: The tower-http layers

The stack in the complete example uses these layers, from outermost to innermost:

  • SetRequestIdLayer adds an x-request-id header when the request has none, generating a UUID with MakeRequestUuid. A caller-supplied id is kept.
  • TraceLayer::new_for_http creates a tracing span per request and logs start and finish. Observability extends it.
  • PropagateRequestIdLayer copies the id to the response, so a client can quote it in a support request.
  • CorsLayer answers browser preflight requests and adds CORS headers.
  • HandleErrorLayer with tower::timeout::TimeoutLayer limits each request to one second.
  • RequestBodyLimitLayer rejects bodies over the limit.
  • CompressionLayer compresses response bodies with a scheme listed in Accept-Encoding.

An id supplied by an external client is only a label and may contain any text. Generate a fresh one at the edge when it is written into a system that parses it.

Example 5: CORS, limits, and timeouts

  • CORS (cross-origin resource sharing) lets a browser page on one origin read responses from another. For requests with custom headers, PUT, or JSON bodies, the browser first sends a preflight OPTIONS request. CORS is enforced by browsers only and does not protect an API from curl or other servers. A wildcard origin cannot be combined with cookies. Service-to-service calls do not need CORS.
  • Body limits: Axum's Json, Form, Bytes, and String extractors apply a 2 MB default. DefaultBodyLimit changes that limit for the extractors. RequestBodyLimitLayer enforces a cap on the body itself and answers 413 when the Content-Length header exceeds it. Limits on headers, connections, and slow uploads belong at the gateway.
  • Fallible layers: Router::layer requires an infallible service, because Axum must turn every failure into a response. tower::timeout::TimeoutLayer returns an Elapsed error, so HandleErrorLayer sits outside it and maps the error to a response. The tower-http timeout layer produces a response itself. This lesson uses the tower layer with HandleErrorLayer.

Complete example

File tree:

Text
storefront-middleware/
  Cargo.toml
  src/
    main.rs
    log_layer.rs
    middleware.rs

Cargo.toml

TOML
[package]
name = "storefront-middleware"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["trace", "cors", "limit", "compression-gzip", "request-id", "propagate-header"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde_json = "1"

src/log_layer.rs

Rust
use axum::http::Request;
use std::task::{Context, Poll};
use tower::{Layer, Service};

#[derive(Clone)]
pub struct LogLayer;

impl<S> Layer<S> for LogLayer {
    type Service = LogService<S>;

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

#[derive(Clone)]
pub struct LogService<S> {
    inner: S,
}

impl<S, B> Service<Request<B>> for LogService<S>
where
    S: Service<Request<B>>,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = S::Future;

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

    fn call(&mut self, request: Request<B>) -> Self::Future {
        println!("{} {}", request.method(), request.uri().path());
        self.inner.call(request)
    }
}

src/middleware.rs

Rust
use crate::AppState;
use axum::{
    extract::{Request, State},
    http::{HeaderValue, StatusCode},
    middleware::Next,
    response::Response,
};

#[derive(Clone)]
pub struct RequestContext {
    pub request_id: String,
}

pub async fn require_api_key(
    State(state): State<AppState>,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let supplied = request.headers().get("x-api-key").and_then(|v| v.to_str().ok());
    if supplied != Some(state.api_key.as_str()) {
        return Err(StatusCode::UNAUTHORIZED);
    }
    Ok(next.run(request).await)
}

pub async fn attach_context(mut request: Request, next: Next) -> Response {
    let request_id = request
        .headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("none")
        .to_string();
    request.extensions_mut().insert(RequestContext { request_id });
    next.run(request).await
}

pub async fn add_server_header(request: Request, next: Next) -> Response {
    let mut response = next.run(request).await;
    response
        .headers_mut()
        .insert("x-served-by", HeaderValue::from_static("storefront"));
    response
}

src/main.rs

/health is public. /orders and /slow require the header x-api-key: secret. The timeout is one second and the body limit is 1024 bytes.

Rust
mod log_layer;
mod middleware;

use axum::{
    error_handling::HandleErrorLayer,
    http::{HeaderName, HeaderValue, Method, StatusCode},
    middleware::{from_fn, from_fn_with_state},
    routing::get,
    BoxError, Extension, Json, Router,
};
use log_layer::LogLayer;
use middleware::{add_server_header, attach_context, require_api_key, RequestContext};
use serde_json::{json, Value};
use std::time::Duration;
use tower::{timeout::TimeoutLayer, ServiceBuilder};
use tower_http::{
    compression::CompressionLayer,
    cors::CorsLayer,
    limit::RequestBodyLimitLayer,
    request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
    trace::TraceLayer,
};

#[derive(Clone)]
pub struct AppState {
    pub api_key: String,
}

async fn health() -> &'static str {
    "ok"
}

async fn list_orders(Extension(ctx): Extension<RequestContext>) -> Json<Value> {
    Json(json!({ "request_id": ctx.request_id, "orders": [] }))
}

async fn create_order(Json(_order): Json<Value>) -> (StatusCode, Json<Value>) {
    (StatusCode::CREATED, Json(json!({ "created": true })))
}

async fn slow() -> &'static str {
    tokio::time::sleep(Duration::from_secs(3)).await;
    "done"
}

async fn handle_timeout(error: BoxError) -> (StatusCode, &'static str) {
    if error.is::<tower::timeout::error::Elapsed>() {
        (StatusCode::REQUEST_TIMEOUT, "request timed out")
    } else {
        (StatusCode::INTERNAL_SERVER_ERROR, "internal error")
    }
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_env_filter("info,tower_http=debug").init();

    let state = AppState { api_key: "secret".to_string() };
    let x_request_id = HeaderName::from_static("x-request-id");

    let cors = CorsLayer::new()
        .allow_origin("https://shop.example.com".parse::<HeaderValue>().unwrap())
        .allow_methods([Method::GET, Method::POST])
        .allow_headers([axum::http::header::CONTENT_TYPE, HeaderName::from_static("x-api-key")]);

    let protected = Router::new()
        .route("/orders", get(list_orders).post(create_order))
        .route("/slow", get(slow))
        .route_layer(from_fn_with_state(state.clone(), require_api_key));

    let app = Router::new()
        .route("/health", get(health))
        .merge(protected)
        .layer(
            ServiceBuilder::new()
                .layer(SetRequestIdLayer::new(x_request_id.clone(), MakeRequestUuid))
                .layer(TraceLayer::new_for_http())
                .layer(PropagateRequestIdLayer::new(x_request_id))
                .layer(cors)
                .layer(HandleErrorLayer::new(handle_timeout))
                .layer(TimeoutLayer::new(Duration::from_secs(1)))
                .layer(RequestBodyLimitLayer::new(1024))
                .layer(CompressionLayer::new())
                .layer(from_fn(attach_context))
                .layer(from_fn(add_server_header))
                .layer(LogLayer),
        )
        .with_state(state);

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

Run:

Terminal
cargo run

Requests:

Terminal
req() { curl -s -w ' [%{http_code}]\n' "$@"; }

req localhost:3000/health
req localhost:3000/orders
req -H 'x-api-key: secret' -H 'x-request-id: demo-1' localhost:3000/orders
curl -s -D - -o /dev/null -H 'x-api-key: secret' localhost:3000/orders | grep -i '^x-served-by'
req -X POST -H 'x-api-key: secret' -H 'content-type: application/json' -d '{"sku":"A1"}' localhost:3000/orders
head -c 2000 /dev/zero | tr '\0' 'a' > big.txt
req -X POST -H 'x-api-key: secret' -H 'content-type: application/json' --data-binary @big.txt localhost:3000/orders
req -H 'x-api-key: secret' localhost:3000/slow
curl -s -D - -o /dev/null -X OPTIONS -H 'Origin: https://shop.example.com' -H 'Access-Control-Request-Method: POST' localhost:3000/orders | grep -i '^access-control-allow-origin'
Output
ok [200]
 [401]
{"orders":[],"request_id":"demo-1"} [200]
x-served-by: storefront
{"created":true} [201]
 [413]
request timed out [408]
access-control-allow-origin: https://shop.example.com
  • The 401 has an empty body because require_api_key returns a bare status code.
  • Without x-request-id in the request, the id in the response and in RequestContext is a generated UUID.
  • The slow handler sleeps three seconds, so the one second timeout answers first.

Summary

  • Every Axum middleware is a Tower Layer around a Service.
  • With Router::layer, the last layer added runs first on the request. ServiceBuilder lists layers in request order.
  • from_fn writes application middleware as an async function taking Request and Next.
  • tower-http provides request id, tracing, CORS, compression, body limits, and timeouts.
  • Request id goes first, tracing next, CORS before authentication, and timeouts around slow work.
  • CORS is browser policy, not access control.
  • Fallible Tower layers need HandleErrorLayer so the error becomes a response.
  • Extensions are typed and checked at runtime. A missing one is a server error.