How to propagate per-request context in a type-safe manner within an axum + tower middleware chain
If you've used Express middleware in Node.js or context.WithValue with the chi router in Go, you know how easy it is to pass per-request data down to handlers — and how quietly it can fall apart. You've probably spent hours tracking down a bug where ctx.Value(authKey) returns nil, the compiler says nothing, and the handler runs without authentication or panics at runtime.
Combining Rust's axum with tower lets you push much of this problem to compile time — though not entirely, as we'll cover later. State<T> guarantees shared state via type parameters, TypedHeader<T> catches header parsing errors at extraction time, and Extension<T> holds request-scoped data in a runtime type map. By clearly separating the roles of these three mechanisms and stacking the middleware chain in the right order, authentication, tracing, and rate-limit context propagate safely along the request flow. That said, the runtime pitfalls that Extension<T> leaves behind require separate defense.
This article is aimed not at those new to axum and tower, but at those already running Node.js or Go backends who are evaluating whether to introduce Rust on performance-critical paths. The code is based on the axum 0.8 family and surrounding crates as of August 2026. APIs shift slightly with each minor version, so check your own Cargo.lock and documentation when working on a real project.
Why axum Adopted tower Wholesale
The most distinctive aspect of axum's design is that it has no middleware system of its own. Instead, it uses the tower::Service and tower::Layer traits directly. At first I wondered why yet another abstraction layer was introduced, but the benefits of this design are substantial.
tower is the shared abstraction layer across Hyper, Tonic (gRPC), and axum. TraceLayer, CorsLayer, CompressionLayer, and TimeoutLayer, already implemented in tower-http, can be used in axum as-is, without any additional wrapping. It's a similar feel to attaching net/http middleware to chi or gorilla/mux in the Go ecosystem, but with a much stronger type system.
Layers operate bidirectionally. The request passes through each layer on the way down, and the response passes back through them in reverse order on the way up. Keeping this flow in mind makes middleware ordering issues much easier to understand.
Three Mechanisms for Context Propagation
Honestly, at first I found it confusing what separated State from Extension. The role distinction only became clear after using them hands-on.
| Mechanism | Type Safety | Primary Use |
|---|---|---|
State<T> |
Compile time | Shared immutable state: DB pools, config, etc. |
Extension<T> |
Runtime (type-keyed map) | Request-scoped data passed from middleware to handlers |
TypedHeader<T> |
Compile time | Standard headers: Authorization, Content-Type, etc. |
State<T> is attached to the router once with .with_state(), and any handler that requires that type can retrieve it. The compiler checks the type, so a build failure results from inserting the wrong type.
Extension<T> works by having middleware insert a value via req.extensions_mut().insert(value), and having the handler retrieve it with the Extension<T> extractor. Since it's a runtime map keyed by type, there is no compile-time guarantee. If you extract Extension<T> on a route that doesn't have the middleware applied, the code compiles but a runtime 500 error occurs. This is the most important pitfall to be aware of, and defensive patterns are covered in a later section.
TypedHeader<T> is provided by the axum-extra crate. TypedHeader is an API that already moved to axum-extra at the time of axum 0.6 — it wasn't split off recently but has been in that location for years. It parses headers into structs and injects them directly into handlers via the extractor mechanism, returning a 400-level response on parse failure. Keep in mind that using it with custom headers requires a manual implementation of the headers::Header trait (covered later).
Choosing a Middleware Authoring Style
There are three ways to write middleware in tower.
1. axum::middleware::from_fn
The simplest approach, sufficient for most situations. However, for authentication logic you must manage response semantics when a header is missing. Using TypedHeader<Authorization<Bearer>> directly returns a 400-level response when the header is absent, but in an authentication context 401 Unauthorized is the correct status. So it's more accurate to receive the header as optional or pull it directly from the header map and explicitly construct a 401.
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{header, StatusCode},
middleware::Next,
response::Response,
};
async fn auth_middleware(
State(db): State<Arc<Database>>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = validate_token(token, &db)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(user);
Ok(next.run(req).await)
}The semantics are unified to return 401 when the header is missing and also when the token is invalid. If you really want to use TypedHeader, you can receive it as Option<TypedHeader<Authorization<Bearer>>> and map None to 401.
2. Implementing FromRequestParts / FromRequest
For cases that only need request parts, such as header parsing or cookie validation, implement FromRequestParts; implement FromRequest when you also need to read the body. The extractor itself carries the authentication logic, allowing you to use domain types like AuthUser directly in handler signatures.
The axum 0.8 family uses native async fn in trait (AFIT) from Rust 1.75+, so you no longer need to attach #[async_trait] as before. If you're migrating from a codebase using axum 0.7 or earlier, removing the attribute is required for compilation.
use axum::{
extract::{Extension, FromRequestParts},
http::{request::Parts, StatusCode},
RequestPartsExt,
};
pub struct AuthUser(pub User);
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let Extension(user) = parts
.extract::<Extension<User>>()
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user))
}
}With this wrapper, handlers can destructure it as AuthUser(user): AuthUser, and when Extension<User> is absent it returns an explicit 401 instead of 500. This is a practical pattern for defending against the Extension<T> runtime pitfall at the domain layer.
3. Implementing tower::Service Directly
Use this only when you need maximum control. Because it requires understanding Rust async internals like Poll::Ready and BoxFuture, it's better to consider from_fn first unless you have a specific reason.
Building a Real Chain: Tracing, Rate Limiting, Authentication
Where route_layer and .layer() Apply
The order in which layers are stacked inside ServiceBuilder matches execution order, but things get a bit more nuanced when you mix Router's .layer() and .route_layer(). The rules are:
- A layer attached with
route_layer()applies only to that route. - A layer attached with
.layer()becomes the outer wrapper around everything attached up to that point. - Therefore, if you attach authentication with
route_layerfirst and then attachTraceLayerwith.layer()later, the execution order isTraceLayer→ authentication → handler.
A useful mental shorthand: "the later something is attached with .layer(), the more outer it is."
use axum::{routing::{get, post}, Router, middleware};
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
let public_routes = Router::new()
.route("/health", get(health_handler))
.route("/auth/login", post(login_handler));
let protected_routes = Router::new()
.route("/api/users", get(users_handler))
.route("/api/users/{id}", get(user_handler))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.merge(public_routes)
.merge(protected_routes)
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(GovernorLayer { config: governor_config.clone() }),
)
.with_state(state);There is a reason for ordering tracing → rate limiting → authentication. Tracing must be outermost so that authentication failures and rate-limit exceeded responses are also recorded in spans. Placing authentication inside rate limiting also means that traffic with invalid tokens still consumes rate-limit counters, which is advantageous for abuse prevention.
Distributed Tracing: Automatic W3C Trace Context Propagation
The axum-tracing-opentelemetry crate automatically extracts inbound traceparent headers and links span contexts. It's safer to check the exact version on each crate's crates.io page.
use axum_tracing_opentelemetry::middleware::OtelAxumLayer;
use opentelemetry::global;
use opentelemetry_sdk::propagation::TraceContextPropagator;
global::set_text_map_propagator(TraceContextPropagator::new());
let app = Router::new()
.route("/api/resource", get(resource_handler))
.layer(
ServiceBuilder::new()
.layer(OtelAxumLayer::default())
.layer(TraceLayer::new_for_http()),
);When creating child spans inside handlers, do not use span.enter() — meant for synchronous code — inside async functions. When the tokio runtime moves a task to a different thread, the span stack becomes corrupted. Instead, attach spans to the future itself using the Instrument trait.
use tracing::Instrument;
async fn resource_handler(
Extension(user): Extension<User>,
) -> impl IntoResponse {
async move {
Json(fetch_data().await)
}
.instrument(tracing::info_span!("fetch_resource", user_id = %user.id))
.await
}Alternatively, if you want to attach a span to the entire function, use the #[tracing::instrument] attribute. This is the more idiomatic approach.
#[tracing::instrument(skip(user), fields(user_id = %user.id))]
async fn resource_handler(
Extension(user): Extension<User>,
) -> impl IntoResponse {
Json(fetch_data().await)
}IP-Based Rate Limiting
tower-governor is based on the GCRA algorithm from the governor crate. Using SmartIpKeyExtractor determines the client IP by checking X-Forwarded-For → X-Real-IP → peer IP in that order. Always validate the list of trusted headers against your infrastructure configuration when running behind a reverse proxy.
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
let governor_config = Arc::new(
GovernorConfigBuilder::default()
.per_second(50)
.burst_size(100)
.use_headers()
.finish()
.unwrap(),
);
let app = Router::new()
.route("/api/resource", get(resource_handler))
.layer(GovernorLayer { config: governor_config });When the rate limit is exceeded, 429 Too Many Requests and a Retry-After header are returned automatically.
Multi-Tenant Context: Connection Pools and Schema Isolation
A common pattern is identifying tenants by API key. Here are two mistakes to watch out for upfront.
First, using a custom header type like TypedHeader<XApiKey> requires a manual implementation of the headers::Header trait. Using it without that implementation won't compile. For a simple API key, pulling it from HeaderMap directly or writing a custom extractor is often lighter-weight.
Second, SET search_path is a connection session-level setting. If you set SET search_path on a connection retrieved from the pool and return it, the next tenant to reuse that connection will inherit the previous value, potentially leading to cross-tenant data exposure. There are two main isolation approaches:
- Use
SET LOCALscoped to a transaction: The setting is automatically reverted at commit or rollback. SinceSETdoesn't support parameter binding, validate schema names as safe identifiers before interpolating them. - Schema-qualified queries: If the application always constructs queries like
"tenant_a"."users", it never depends on session state, which is the safest approach.
Below is a conceptual example of the transaction-scoped approach.
use axum::{
extract::{Extension, Request, State},
http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Clone)]
struct TenantContext {
tenant_id: Uuid,
schema: String,
}
async fn tenant_middleware(
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let api_key = req
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let tenant = resolve_tenant(api_key)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(TenantContext {
tenant_id: tenant.id,
schema: tenant.db_schema,
});
Ok(next.run(req).await)
}
fn is_safe_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
async fn data_handler(
Extension(tenant): Extension<TenantContext>,
State(pool): State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
if !is_safe_ident(&tenant.schema) {
return Err(StatusCode::BAD_REQUEST);
}
let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let stmt = format!("SET LOCAL search_path TO {}", tenant.schema);
sqlx::query(&stmt)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rows: Vec<Row> = sqlx::query_as("SELECT id, name FROM widgets")
.fetch_all(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(rows))
}SET LOCAL reverts the moment the transaction ends, so even if the connection is returned to the pool and picked up by another tenant, the previous schema does not persist. Schema name validation is the minimum requirement for SQL injection defense.
Common Pitfalls and Trade-offs
Extension<T> Runtime Risk
Mentioned earlier, but worth emphasizing again. Extracting Extension<User> on a route outside the middleware's scope compiles fine but produces a runtime 500. Two defensive approaches:
- Receive it as
Option<Extension<User>>and handle it explicitly. - Wrap it in
FromRequestPartsto create a domain extractor likeAuthUser, and return an intended response code such as 401 or 403 on internal failure.
Because of this pitfall, the central claim of this article — "promoting runtime errors to compile time" — must acknowledge some exceptions remain. Extension<T> is a convenient tool that demands discipline.
Middleware Ordering Mistakes
| Wrong Order | Problem |
|---|---|
| Authentication placed outside tracing | Authentication failure responses are not recorded in spans |
| Rate limiting placed outside tracing | 429 responses carry no trace ID |
| Authentication placed outside rate limiting | Requests with invalid tokens bypass rate limiting |
| Rate limiting placed outside authentication (recommended) | Invalid tokens still consume rate-limit counters |
Compile Time
Large middleware chains can increase compile time due to generic monomorphization. During development, rely on cargo check and incremental builds as much as possible. When necessary, you can erase types with tower::util::BoxLayer to separate compilation units.
Ecosystem Maturity and Performance
There are fewer high-level plugin options compared to Express or Go's chi. You may need to implement more components yourself, such as ORMs and authentication libraries.
This article will not quote specific performance numbers. Throughput comparisons between stacks vary greatly depending on hardware, workload, connection models, and tuning, and reliable comparisons come from public benchmarks like TechEmpower Framework Benchmarks or your own measurements against traffic that approximates your production load. Single-source marketing figures are poor grounds for decision-making.
| Aspect | Node.js Express | Go chi | Rust axum |
|---|---|---|---|
| Middleware type safety | Runtime | Partial | Compile + runtime mix |
| Throughput characteristics | Single-threaded event loop | Lightweight goroutine parallelism | Multi-threaded async runtime |
| Middleware ecosystem | Mature | Mature | Growing |
| Learning curve | Low | Medium | High (ownership & lifetimes) |
| Compile overhead | None | Low | Medium–High |
Closing: When to Use This Stack, and When to Wait
The core of the axum + tower combination is separation of concerns. State<T> for immutable state shared across the app, Extension<T> for dynamic data injected per-request by middleware, and TypedHeader for type-safe parsing of standard headers. Using each according to its role rather than mixing them allows a significant portion of runtime errors to be elevated to compile time.
However, it is not full compile-time safety. Extension<T> still blows up at runtime if you forget to attach the middleware, and there are areas the type system cannot catch — such as session state isolation with search_path. These points require separate discipline: FromRequestParts wrappers, transaction scoping, schema name whitelists, and so on.
When axum is worth serious consideration: services where throughput and tail latency directly impact revenue or UX, background workers where long-running stability is critical, and teams that already have Rust knowledge.
When it's better to wait for now: when the team lacks the capacity to absorb the learning curve, when the required domain libraries are not yet mature for a given area (certain SaaS SDKs, for example), or during early product stages where feature velocity matters far more than performance.
An incremental approach is safer. Pick one service with a clear performance bottleneck, migrate it to axum, solidify the middleware ordering and context propagation patterns covered here as team idioms, and then expand to the next service. That's the realistic path.
References
- axum official documentation
- axum::middleware module documentation
- TypedHeader in axum-extra
- tower crate documentation
- tower-http crate documentation
- tower-governor GitHub
- axum-tracing-opentelemetry (crates.io)
- tracing::Instrument trait
- W3C Trace Context specification
- TechEmpower Framework Benchmarks
- State vs Extensions discussion — tokio-rs/axum Discussions