Middleware in Axum is Tower middleware. Once you see that, the patterns become obvious. This post is the working set for production services.

The two ways

// 1. from_fn — ergonomic
let app = Router::new()
    .route("/health", get(health))
    .layer(middleware::from_fn(request_id));

// 2. Custom Tower Layer — full control
let app = Router::new()
    .route("/health", get(health))
    .layer(RequestIdLayer::new());

For 80% of cases, from_fn is enough.

Request ID

Always-on middleware that tags every request:

use axum::{extract::Request, middleware::Next, response::Response};
use uuid::Uuid;

pub async fn request_id(mut req: Request, next: Next) -> Response {
    let id = req.headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .map(String::from)
        .unwrap_or_else(|| Uuid::new_v4().to_string());

    req.extensions_mut().insert(RequestId(id.clone()));
    let mut res = next.run(req).await;
    res.headers_mut().insert("x-request-id", id.parse().unwrap());
    res
}

Every log line, every error, every span carries this ID. Correlates everything.

Auth middleware

pub async fn auth(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, AppError> {
    let token = req.headers()
        .get(AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .ok_or(AppError::Unauthorized)?;

    let user = state.auth.verify(token).await
        .map_err(|_| AppError::Unauthorized)?;

    req.extensions_mut().insert(user);
    Ok(next.run(req).await)
}

// Usage
let protected = Router::new()
    .route("/me", get(me_handler))
    .layer(middleware::from_fn_with_state(state.clone(), auth));

Handlers extract Extension<User> to access the authenticated user. See Production Rust on Axum .

Rate limiting

For per-IP / per-user limits, Tower has tower_governor:

use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};

let governor = GovernorConfigBuilder::default()
    .per_second(10)
    .burst_size(20)
    .finish()
    .unwrap();

let app = Router::new()
    .route("/api", get(handler))
    .layer(GovernorLayer { config: governor.into() });

For more control: implement a custom layer backed by Redis. See Rate Limiting Patterns .

Tracing / observability

use tower_http::trace::TraceLayer;

let app = Router::new()
    .route("/", get(home))
    .layer(TraceLayer::new_for_http()
        .make_span_with(|req: &Request<_>| {
            tracing::info_span!(
                "http_request",
                method = %req.method(),
                uri = %req.uri(),
                request_id = req.extensions()
                    .get::<RequestId>()
                    .map(|r| r.0.as_str())
                    .unwrap_or(""),
            )
        }));

Combined with tracing-subscriber and OTLP export, every request becomes a distributed trace span. See Observability Stack .

Error mapping

Convert internal errors to HTTP responses with one shared type:

pub enum AppError {
    Unauthorized,
    NotFound,
    BadRequest(String),
    Internal(anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, body) = match self {
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found".into()),
            AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
            AppError::Internal(e) => {
                tracing::error!(?e, "internal error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal".into())
            }
        };
        (status, Json(json!({ "error": body }))).into_response()
    }
}

Handlers return Result<T, AppError>. No unwrap in the hot path. See Rust Error Handling .

CORS

use tower_http::cors::{Any, CorsLayer};

let cors = CorsLayer::new()
    .allow_origin(["https://rajpoot.dev".parse().unwrap()])
    .allow_methods([Method::GET, Method::POST])
    .allow_headers([AUTHORIZATION, CONTENT_TYPE])
    .max_age(Duration::from_secs(3600));

let app = Router::new().route("/", get(h)).layer(cors);

Always pin allowed origins; never Any in production.

Compression

use tower_http::compression::CompressionLayer;

let app = Router::new()
    .route("/", get(home))
    .layer(CompressionLayer::new());

Gzip / brotli per request. Cheap; large payload savings on JSON APIs.

Timeout

use tower_http::timeout::TimeoutLayer;

let app = Router::new()
    .route("/", get(slow))
    .layer(TimeoutLayer::new(Duration::from_secs(30)));

Defense against slow handlers. Pair with per-handler tighter limits where applicable.

Composition order

Layers are applied in reverse order:

let app = Router::new()
    .route("/", get(h))
    .layer(auth_layer)        // applied 3rd (innermost)
    .layer(trace_layer)       // applied 2nd
    .layer(request_id_layer); // applied 1st (outermost)

Request ID first → trace span → auth → handler. On the way out: handler → auth → trace → request ID.

Get this wrong and your traces lack request ID, or your auth runs before request ID assignment.

Custom Layer

When from_fn isn’t enough:

#[derive(Clone)]
pub struct MetricsLayer { metrics: Arc<Metrics> }

impl<S> Layer<S> for MetricsLayer {
    type Service = MetricsService<S>;
    fn layer(&self, inner: S) -> Self::Service {
        MetricsService { inner, metrics: self.metrics.clone() }
    }
}

#[derive(Clone)]
pub struct MetricsService<S> { inner: S, metrics: Arc<Metrics> }

impl<S, B> Service<Request<B>> for MetricsService<S>
where S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
      S::Future: Send + 'static, B: Send + 'static,
{
    // ... poll_ready + call ...
}

More boilerplate; full control over the request/response lifecycle.

Common mistakes

1. Wrong layer order

Auth applied before request ID → traces lose correlation. Compression applied before tracing → trace records compressed bytes. Order matters.

2. Heavy work in middleware

Every request pays the cost. Cache lookups, async DB calls, etc. — measure first.

3. Panic in middleware

Brings down the request. Always return errors via the result type; never panic.

4. Forgetting request body

Middleware that reads the body must put it back: axum::body::to_bytes then reconstruct. Otherwise the handler gets an empty body.

5. State cloning everywhere

AppState is Arc-wrapped; clone is cheap. But cloning huge non-Arc state in middleware is a perf trap.

Read this next

If you want my Axum middleware starter pack (auth + request ID + tracing + error mapping), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .