Updated Aug 27, 2026

06. Middleware and Layers

📋 Jump to Takeaways

🎁 In Go you wrap http.Handler with another http.Handler. In Axum you stack Layers. Same idea, different types — and Rust's type system ensures you can't wire them incorrectly.

How Axum Middleware Works

Axum is built on tower, a library for composable async services. Middleware in tower is a Layer — something that wraps a service and adds behavior. You stack layers onto your router:

use axum::Router;
use tower_http::trace::TraceLayer;

let app = Router::new()
    .route("/health", get(health))
    .layer(TraceLayer::new_for_http()); // wraps all routes

Layers apply outside-in: the last .layer() call is the outermost wrapper — it runs first on request and last on response.

tower-http

[dependencies]
tower-http = { version = "0.6", features = ["trace", "cors", "request-id"] }
tower = "0.5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

Structured Logging with tracing

TraceLayer emits structured spans for every request. Set up tracing-subscriber first so the spans are printed:

use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

fn init_tracing() {
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();
}

Call init_tracing() at the top of main before anything else.

Now add TraceLayer:

use tower_http::trace::TraceLayer;

let app = Router::new()
    .route("/health", get(health))
    .layer(TraceLayer::new_for_http());

Every request now logs method, path, status, and duration automatically.

Request IDs

Attach a unique ID to every request for log correlation:

use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer, PropagateRequestIdLayer};
use axum::http::HeaderName;

let request_id_header = HeaderName::from_static("x-request-id");

let app = Router::new()
    .route("/health", get(health))
    .layer(PropagateRequestIdLayer::new(request_id_header.clone()))
    .layer(TraceLayer::new_for_http())
    .layer(SetRequestIdLayer::new(request_id_header, MakeRequestUuid));

SetRequestIdLayer generates a UUID and puts it in the request header. PropagateRequestIdLayer copies it to the response header so clients can trace requests.

CORS

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

let cors = CorsLayer::new()
    .allow_origin(Any)
    .allow_methods(Any)
    .allow_headers(Any);

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

In production, replace Any with your actual allowed origins:

use axum::http::HeaderValue;

.allow_origin("https://yourdomain.com".parse::<HeaderValue>().unwrap())

Writing Custom Middleware

For simple cases, use axum::middleware::from_fn:

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

async fn require_api_key(
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let key = request.headers()
        .get("x-api-key")
        .and_then(|v| v.to_str().ok());

    match key {
        Some(k) if k == "secret" => Ok(next.run(request).await),
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}

// Apply only to specific routes:
let protected = Router::new()
    .route("/links", post(create_link))
    .route_layer(middleware::from_fn(require_api_key));

let app = Router::new()
    .route("/health", get(health))
    .merge(protected);

route_layer applies middleware only to the routes in that router. .layer() applies it to everything.

Key Takeaways

  • Axum middleware uses tower's Layer trait — stack them with .layer()
  • Layers apply outside-in: the last .layer() is the outermost wrapper
  • tower-http provides production-ready layers: tracing, CORS, request IDs, compression
  • TraceLayer logs method, path, status, and duration for every request automatically
  • from_fn is the simplest way to write custom middleware
  • route_layer applies middleware only to specific routes; .layer() applies to all

🎁 Your service handles requests, but the data doesn't persist anywhere. Every restart loses everything. Next up: connecting to PostgreSQL with SQLx — async, type-safe, and with compile-time query checking.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy