Building Modular Web Services with Axum Layers for Observability and Security
James Reed
Infrastructure Engineer · Leapcell

Introduction
In the rapidly evolving landscape of web service development, building robust, scalable, and maintainable applications is paramount. Observability (logging and tracing) and security (authentication) are not mere afterthoughts but fundamental pillars of any production-grade system. As our services grow in complexity, integrating these cross-cutting concerns effectively and non-invasively becomes a significant challenge. This is where middleware shines, offering a powerful pattern to encapsulate such concerns separately from core business logic. Axum, a popular web framework in the Rust ecosystem, provides an elegant and flexible mechanism for implementing middleware through its "Layer" system, largely inspired by the Tower project. Understanding and leveraging Axum Layers empowers developers to construct highly modular and observable services, injecting essential functionalities like logging, authentication, and tracing with remarkable ease and reusability. This article will explore the internals of Axum Layers and guide you through building custom middleware for these critical aspects, illustrating how they can drastically improve the structure and maintainability of your Rust web applications.
Understanding Axum Layers
Before diving into implementation, let's clarify some core terminology associated with Axum Layers.
Service: In the context of Tower and Axum, a Service
is a trait that defines an asynchronous function that takes a request and returns a future of a response or an error. It's the fundamental building block for handling requests. Your Axum handlers are effectively services.
Layer: A Layer
is a trait that takes a Service
and wraps it, returning a new Service
. This new service can then perform operations before, after, or even instead of calling the underlying service. Layers are composable, meaning you can stack multiple layers to create a pipeline of processing steps.
Middleware: While Layer
is the specific Tower trait, "middleware" is the more general concept it implements. Middleware intercepts requests and responses, performing supplementary tasks like logging, authentication, Caching, etc.
Tower: Tower is a library of modular and reusable components for building robust client and server applications. Axum leverages Tower's Service
and Layer
traits extensively, providing a powerful and idiomatic way to extend its functionality.
The Principle of Axum Layers
The power of Axum Layers lies in their composability. Each layer acts as a decorator, adding functionality to the service it wraps. When a request comes in, it passes through each layer in the order they are applied, then reaches the innermost service (your handler), and finally, the response travels back out through the layers in reverse order. This "onion-skin" model allows for clear separation of concerns.
A custom Layer typically involves defining two main components:
- The Layer struct: This struct implements the
tower::Layer
trait. Itslayer
method takes an inner service and returns a newService
that wraps it. - The Service struct: This struct implements the
tower::Service
trait. It holds a reference to the inner service and encapsulates the logic that executes before or after calling the inner service.
Let's illustrate this with practical examples for logging, authentication, and tracing.
Custom Logging Layer
A logging layer is crucial for understanding how your application behaves in production, tracking errors, and monitoring request flow.
use axum::{ body::{Body, BoxBody}, http::{Request, Response, StatusCode}, middleware::Next, response::IntoResponse, routing::get, Router, }; use std::{ task::{Context, Poll}, time::Instant, }; use tower::{Layer, Service}; use tracing::{info, instrument}; // 1. Define the Logging Layer #[derive(Debug, Clone)] struct LogLayer; impl<S> Layer<S> for LogLayer { type Service = LogService<S>; fn layer(&self, inner: S) -> Self::Service { LogService { inner } } } // 2. Define the Logging Service #[derive(Debug, Clone)] struct LogService<S> { inner: S, } impl<S, B> Service<Request<B>> for LogService<S> where S: Service<Request<B>, Response = Response<BoxBody>> + Send + 'static, S::Future: Send + 'static, B: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; 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 { let path = request.uri().path().to_owned(); let method = request.method().to_string(); info!(%method, %path, "Incoming request"); let start = Instant::now(); let future = self.inner.call(request); Box::pin(async move { let response = future.await?; let latency = start.elapsed(); let status = response.status(); info!(%method, %path, %status, "Request finished in {}ms", latency.as_millis()); Ok(response) }) } } // Helper function to create the layer (optional, but convenient) fn log_layer() -> LogLayer { LogLayer }
In this example:
LogLayer
simply holds no state and implements theLayer
trait. Itslayer
method creates aLogService
instance, wrapping the inner service.LogService<S>
holds theinner
service. Itscall
method is where the actual logging logic resides. It logs before calling theinner.call()
and then logs again after the response is received, including the request method, path, status, and latency.- We use
tracing
for structured logging, which is highly recommended in Rust.
Custom Authentication Layer
An authentication layer ensures that only authorized requests reach your core business logic, preventing unauthorized access.
use axum::http::HeaderValue; use std::collections::HashMap; // A simple in-memory user store for demonstration lazy_static::lazy_static! { static ref USERS: HashMap<&'static str, &'static str> = { let mut m = HashMap::new(); m.insert("admin", "password123"); m.insert("user", "mysecret"); m }; } // Define an Error type for authentication failures #[derive(Debug)] enum AuthError { InvalidCredentials, MissingCredentials, } impl IntoResponse for AuthError { fn into_response(self) -> Response<BoxBody> { let (status, msg) = match self { AuthError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"), AuthError::MissingCredentials => (StatusCode::UNAUTHORIZED, "Missing Authorization header"), }; (status, msg).into_response() } } // 1. Define the Authentication Layer #[derive(Debug, Clone)] struct AuthLayer; impl<S> Layer<S> for AuthLayer { type Service = AuthService<S>; fn layer(&self, inner: S) -> Self::Service { AuthService { inner } } } // 2. Define the Authentication Service #[derive(Debug, Clone)] struct AuthService<S> { inner: S, } impl<S, B> Service<Request<B>> for AuthService<S> where S: Service<Request<B>, Response = Response<BoxBody>, Error = AuthError> + Send + 'static, // Inner service can return AuthError S::Future: Send + 'static, B: Send + 'static, { type Response = S::Response; type Error = AuthError; // This service can also return AuthError type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request<B>) -> Self::Future { let auth_header = request.headers().get(axum::http::header::AUTHORIZATION); let authenticated = match auth_header { Some(header_value) => { let header_str = header_value.to_str().unwrap_or_default(); if header_str.starts_with("Basic ") { let encoded_credentials = header_str["Basic ".len()..].trim(); let decoded_bytes = base64::decode(encoded_credentials).unwrap_or_default(); let decoded_str = String::from_utf8(decoded_bytes).unwrap_or_default(); let parts: Vec<&str> = decoded_str.split(':').collect(); if parts.len() == 2 { let (username, password) = (parts[0], parts[1]); USERS.get(username) == Some(&password) } else { false } } else { false } } None => false, }; if !authenticated { return Box::pin(async { Err(AuthError::InvalidCredentials) }); } // If authenticated, we could potentially extract user ID and insert it into request extensions // request.extensions_mut().insert(UserId("some-user-id".to_string())); let future = self.inner.call(request); Box::pin(async move { future.await // Pass through to the inner service }) } } // Helper function to create the layer fn auth_layer() -> AuthLayer { AuthLayer }
Key points for authentication:
- We define a custom
AuthError
and implementIntoResponse
for it, so Axum can handle authentication failures gracefully. - The
AuthService
extracts theAuthorization
header, attempts to parse Basic authentication credentials, and checks them against aUSERS
map. - If authentication fails, it immediately returns
Err(AuthError::InvalidCredentials)
. Otherwise, it passes the request to the inner service. - Important: Notice how we define
Error = AuthError
for both theAuthService
and itsinner
service constraint. This means if an inner service also fails withAuthError
, it will propagate correctly.
Custom Tracing Layer
Tracing helps visualize the flow of requests across different services and within a single service, invaluable for debugging distributed systems and performance analysis. Axum already integrates well with tracing
, but a custom layer can enrich traces or add specific context. Often, you'd use a dedicated tracing layer like tower_http::trace::TraceLayer
. However, for educational purposes, let's create a simplified one to demonstrate.
use axum_extra::extract::Extension; use uuid::Uuid; // Data to be injected into the request for tracing #[derive(Clone)] struct RequestId(String); // 1. Define the Tracing Layer #[derive(Debug, Clone)] struct TraceLayer; impl<S> Layer<S> for TraceLayer { type Service = TraceService<S>; fn layer(&self, inner: S) -> Self::Service { TraceService { inner } } } // 2. Define the Tracing Service #[derive(Debug, Clone)] struct TraceService<S> { inner: S, } impl<S, B> Service<Request<B>> for TraceService<S> where S: Service<Request<B>, Response = Response<BoxBody>> + Send + 'static, S::Future: Send + 'static, B: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request<B>) -> Self::Future { // Generate a unique request ID let request_id = Uuid::new_v4().to_string(); info!(request_id = %request_id, "Assigning request ID"); // Insert the request ID into request extensions so handlers can access it request.extensions_mut().insert(RequestId(request_id.clone())); // Also add it as a header for downstream services request.headers_mut().insert("X-Request-ID", HeaderValue::from_str(&request_id).unwrap()); // Create a tracing span for the request let span = tracing::info_span!("request", request_id = %request_id, method = %request.method(), path = %request.uri().path()); let _guard = span.enter(); // Enter the span for the duration of the request let future = self.inner.call(request); Box::pin(async move { let response = future.await?; info!(request_id = %request_id, status = %response.status(), "Request processed"); Ok(response) }) } } // Helper function to create the layer fn trace_layer() -> TraceLayer { TraceLayer }
For tracing:
- We use
uuid
to generate a uniquerequest_id
. - This ID is inserted into
request.extensions_mut()
, making it accessible to subsequent layers and Axum handlers usingExtension<RequestId>
. - A
tracing::info_span!
is created, ensuring all logs within this request context automatically include therequest_id
. - The
X-Request-ID
header is added, useful for propagating trace IDs across service boundaries.
Composing Layers into an Axum Router
Now, let's assemble these layers and apply them to an Axum application.
use axum::{ extract::State, middleware, response::Html, routing::{get, post}, Router, }; use std::{sync::Arc, time::Duration}; use tokio::net::TcpListener; use tower_http::{trace::TraceLayer as TowerTraceLayer, ServiceBuilder}; // Using tower-http's trace for better functionality use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; // Handler that requires authentication and can access the request ID #[instrument(skip(State))] async fn protected_handler( State(app_state): State<Arc<String>>, Extension(request_id): Extension<RequestId>, ) -> Html<String> { info!("Accessing protected handler with request ID: {}", request_id.0); Html(format!( "<h1>Hello from protected handler!</h1><p>App State: {}</p><p>Request ID: {}</p>", app_state, request_id.0 )) } // Handler that does not require authentication async fn public_handler() -> Html<String> { info!("Accessing public handler"); Html("<h1>Hello from public handler!</h1>".to_string()) } #[tokio::main] async fn main() { // Initialize tracing tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); info!("Starting server..."); let app_state = Arc::new("My Awesome App".to_string()); // Build our custom layers let custom_layers = ServiceBuilder::new() // Our custom logging layer (should be outer to log everything) .layer(log_layer()) // Our custom tracing layer (should be next to instrument requests) .layer(trace_layer()); // Apply layers to specific routes or the entire router let app = Router::new() .route("/public", get(public_handler)) .route("/protected", get(protected_handler)) .route_layer(middleware::from_fn(|req, next| async { // Apply authentication only to specific routes // Note: Our custom auth layer operates as a Tower Layer // If you need Axum specific middleware functions, use `middleware::from_fn` // For this example, let's use the explicit AuthLayer directly. // This is how you would apply a Tower Layer to a subset of routes. // A more idiomatic Axum way for specific routes authentication // might involve a custom extractor or applying the layer to a nested router. let auth_service = AuthService { inner: next }; // Manually create AuthService for this example auth_service.oneshot(req).await })) .layer(TraceLayer::new()) // Use tower-http's trace layer for comprehensive tracing // .layer(TowerTraceLayer::new_for_http()) // More robust trace layer from tower-http // .layer(custom_layers) // Apply our custom layers here // Layers are applied in reverse order of definition. // The *last* layer added will wrap the *entire* router/service, making it the outermost logic. // Example: .layer(MyOuterLayer).layer(MyInnerLayer) -> Request goes through Outer, then Inner, then handler. .layer(auth_layer()) // Our custom auth layer .layer(custom_layers) // Apply our custom logging and tracing first (outermost) .with_state(app_state); let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap(); info!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
To run this example, you'll need the following dependencies in your Cargo.toml
:
[dependencies] axum = { version = "0.7", features = ["macros"] } tokio = { version = "1.36", features = ["full"] } tower = { version = "0.4", features = ["full"] } tower-http = { version = "0.5", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } futures = "0.3" uuid = { version = "1.7", features = ["v4", "fast-rng"] } base64 = "0.21" lazy_static = "1.4" axum-extra = { version = "0.9", features = ["extract-intervals"] } # For Extension
In the main
function:
- We initialize
tracing_subscriber
for logging output. ServiceBuilder::new().layer(log_layer()).layer(trace_layer())
creates a combined layer from our custom logging and tracing services. Layers are applied from top to bottom in theServiceBuilder
chain, meaninglog_layer
will be outsidetrace_layer
.app.layer(auth_layer())
applies our authentication layer to the entire router. This means every route will go through authentication. If you want selective authentication, you can apply layers to nestedRouter::with_no_routes()
instances.- The
protected_handler
demonstrates how to extract theRequestId
from request extensions. - The server listens on
127.0.0.1:3000
.
To test:
- Public endpoint:
curl http://127.0.0.1:3000/public
(should return public HTML). - Protected endpoint (unauthenticated):
curl http://127.0.0.1:3000/protected
(should return 401 Unauthorized with "Invalid credentials"). - Protected endpoint (authenticated):
curl -H "Authorization: Basic YWRtaW46cGFzc3dvcmQxMjM=" http://127.0.0.1:3000/protected
(should return protected HTML.YWRtaW46cGFzc3dvcmQxMjM=
is base64 encoded "admin").
You will see detailed logs in your console, showing request IDs, methods, paths, statuses, and latencies, demonstrating the effectiveness of the custom layers.
Application Scenarios
- Logging: Every API request needs to be logged for auditing, debugging, and monitoring.
- Authentication/Authorization: Protect specific endpoints or entire segments of your API based on user roles or permissions.
- Tracing: Propagate request IDs for distributed tracing, enabling end-to-end visibility of requests across microservices.
- Rate Limiting: Prevent abuse by limiting the number of requests a client can make within a certain timeframe.
- Request/Response Transformation: Modify headers, compress bodies, or inject common data into requests/responses.
- Metrics: Collect and expose metrics like request count, error rates, and latency for monitoring dashboards.
- CORS: Handle Cross-Origin Resource Sharing headers.
Conclusion
Axum's Layer system, built upon the powerful Tower ecosystem, offers a highly flexible and idiomatic way to implement custom middleware in Rust web applications. By understanding the Layer
and Service
traits, developers can modularize cross-cutting concerns like logging, authentication, and tracing, leading to cleaner, more maintainable, and robust codebases. This approach not only enhances the observability and security of your services but also promotes reusability and separation of concerns, which are critical for building scalable and delightful web applications. Leveraging Axum Layers empowers you to build highly configurable and production-ready Rust web services with confidence.