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 .