If you are learning Axum and the word middleware makes your brain freeze — this guide is for you. We will go from zero to writing real working middleware, one small step at a time.
Updated for Axum 0.8.x — the latest stable version. If you have seen other tutorials using
axum::http::Requestoraxum = "0.7", those are outdated. This guide uses the correct current patterns straight from the official docs.
1. What is Middleware?
Here is the plain-English definition:
Middleware is code that runs between the moment a request arrives at your server and the moment your handler function deals with it.
The Restaurant Analogy
Imagine your web server is a restaurant. Every customer (HTTP request) who walks in goes through several steps before the chef (your handler) cooks their food:
- The doorman checks they are on the guest list → Auth middleware
- The host writes their name in the log book → Logging middleware
- The waiter checks they have not ordered too much already → Rate-limit middleware
- Finally the chef cooks the meal → Your handler function
Middleware is those steps between the door and the kitchen.
// Without middleware
Client ──────────────────────────────> Handler
// With middleware
Client -> Logger -> Auth -> Handler
<- <- <-
(response flows back the same way)
The key insight: middleware runs on the way IN (before handler) and also on the way OUT (after handler). One piece of code, two chances to do work.
2. Why Do We Need Middleware?
Imagine you have 20 routes in your app. Every route needs to check if the user is logged in. Without middleware, you would write the same auth check 20 times — once inside each handler. That is a nightmare to maintain.
Middleware solves this by letting you write that logic once and apply it to all routes automatically. Common uses:
- Logging — record every request: method, path, time taken, status code
- Authentication — verify the user's token before they can access protected routes
- Rate limiting — prevent one user from flooding your server with requests
- CORS headers — allow browsers from other domains to talk to your API
- Compression — automatically gzip response bodies to save bandwidth
- Request ID — stamp every request with a unique ID for debugging
3. How Axum Does Middleware
Axum is built on top of a library called Tower. Tower has a concept called a Layer — a wrapper around your app that intercepts requests. Middleware in Axum is a Tower layer.
The good news: you do not need to understand Tower internals. Axum gives you a helper called from_fn that lets you write a plain async function:
use axum::middleware::from_fn;
// Your middleware is just an async fn
async fn my_middleware(req: Request, next: Next) -> Response {
// ... your logic here
}
// Attach it with .layer()
let app = Router::new()
.route("/hello", get(handler))
.layer(from_fn(my_middleware));
💡
from_fnis the magic adapter. You write a normal async function, andfrom_fnconverts it into the Tower layer format that Axum expects. Always usefrom_fnas a beginner — the alternative is implementing Tower traits manually, which is much harder.
4. The Correct Imports (Axum 0.8)
This is where many beginners hit their first compiler error. Request is a generic type — it needs a body type parameter — so you cannot just write use axum::http::Request and move on.
Axum 0.8 ships a pre-aliased version that already has the body type filled in. Always import Request from axum::extract:
// ✅ CORRECT — pre-aliased, body type already filled in for you
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
// ❌ WRONG — this is the raw generic, compiler will complain
use axum::http::Request;
The full import block you will use for almost every middleware file:
use axum::{
extract::Request, // ← axum::extract, NOT axum::http
middleware::{from_fn, Next},
http::{StatusCode, header}, // header gives you header::AUTHORIZATION etc.
response::{Response, IntoResponse},
};
5. Understanding the Function Signature
With the right imports in place, let us break down the function shape piece by piece.
async fn my_middleware(
req: Request,
next: Next,
) -> Response {
// body
}
async fn
async fn means this is an asynchronous function. All network code in Rust must be async because waiting for a network response without async would freeze the whole server. Every Axum middleware must be async — no exceptions.
req: Request
req is the incoming HTTP request — the axum::extract::Request alias you imported above. Through it you can read:
req.method() // GET, POST, PUT, DELETE, etc.
req.uri() // the URL path like /users or /profile
req.headers() // all HTTP headers: Authorization, Content-Type, etc.
req.extensions() // extra data that other middleware has attached
next: Next
next represents everything that comes after this middleware — the next middleware in the stack and eventually your handler. Think of it as a "proceed" button.
You call next.run(req).await to pass the request forward. If you never call it, the request stops at your middleware — useful for rejecting bad tokens early.
-> Response
Your middleware must always return a Response. There are exactly two ways this happens:
// Way 1: let the handler produce a response
let response = next.run(req).await;
response
// Way 2: reject early, build your own response
return (StatusCode::UNAUTHORIZED, "bad token").into_response();
// next.run() is never called — handler never runs
6. What Goes Inside the Body
The body of your middleware is split into two zones by the next.run() call:
use axum::{extract::Request, middleware::Next, response::Response};
async fn my_middleware(req: Request, next: Next) -> Response {
// ── ZONE 1: runs BEFORE your handler ──────────────────
// Inspect or modify the request here
println!("→ incoming: {} {}", req.method(), req.uri());
let response = next.run(req).await; // <-- THE PIVOT POINT
// ── ZONE 2: runs AFTER your handler ───────────────────
// Inspect or modify the response here
println!("← outgoing: {}", response.status());
response // always return a response
}
📌 The pivot point:
next.run(req).awaitis the most important line in any middleware. Everything before it runs on the request (way in). Everything after it runs on the response (way out). You do not have to use both zones — many middlewares only do work beforenext.run, and just return the response unchanged.
7. Logging Requests
There are two approaches: the easy way using tower-http, and the manual way writing your own.
The Easy Way: TraceLayer
The tower-http crate ships a ready-made logging middleware. First, update Cargo.toml:
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
Then in main.rs:
use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
#[tokio::main]
async fn main() {
// Set up the log printer (prints to terminal)
tracing_subscriber::fmt::init();
let app = Router::new()
.route("/users", get(get_users))
.layer(TraceLayer::new_for_http()); // <-- one line, done!
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await.unwrap();
axum::serve(listener, app).await.unwrap();
}
When a request comes in you will see this automatically:
2024-01-15T10:23:41Z INFO request{method=GET uri=/users}
→ started processing request
→ status=200 OK latency=4ms
→ finished processing request
The Manual Way: Writing Your Own Logger
Sometimes you want full control over the log format:
use axum::{extract::Request, middleware::Next, response::Response};
use std::time::Instant;
async fn logger(req: Request, next: Next) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let start = Instant::now();
// call next and get the response
let response = next.run(req).await;
// now we have the status code and elapsed time
println!(
"{} {} {} {}ms",
method, uri,
response.status(),
start.elapsed().as_millis()
);
// prints: GET /users 200 OK 4ms
response
}
8. Writing Auth Middleware
Auth middleware is the most common custom middleware you will write. The official Axum docs recommend returning Result<Response, StatusCode> — this is cleaner than calling .into_response() manually everywhere, and it is what you will see in real industry code.
use axum::{
extract::Request,
http::{StatusCode, header},
middleware::Next,
response::Response,
};
async fn require_auth(req: Request, next: Next) -> Result<Response, StatusCode> {
// Use header:: constants — cleaner than raw strings like "authorization"
let auth_header = req
.headers()
.get(header::AUTHORIZATION) // ← header::AUTHORIZATION constant
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?; // ← ? bails out early if missing
if auth_header == "Bearer my-secret-token" {
Ok(next.run(req).await) // ← wrap in Ok()
} else {
Err(StatusCode::UNAUTHORIZED) // ← return Err to reject
}
}
💡 Why
Result<Response, StatusCode>? It lets you use the?operator to bail out early on missing or invalid values, which reads much more naturally than nestedmatchblocks. Axum knows how to convert aStatusCodeerror into an HTTP response automatically.
.layer() vs .route_layer()
The official Axum docs distinguish two ways to attach middleware, and this matters for auth:
// .layer() — wraps EVERYTHING: your routes AND the 404 fallback handler
let app = Router::new()
.route("/private", get(private_handler))
.layer(from_fn(require_auth));
// .route_layer() — wraps only your defined routes, NOT the fallback
// This is what the official docs recommend for auth middleware
let app = Router::new()
.route("/private", get(private_handler))
.route_layer(from_fn(require_auth));
For auth middleware, prefer .route_layer(). If someone hits a non-existent URL, the 404 is returned without ever touching your auth logic — which is usually the right behaviour.
9. Stacking Multiple Middlewares
You can add as many .layer() calls as you want. They wrap each other like layers of an onion.
let app = Router::new()
.route("/dashboard", get(dashboard))
.route_layer(from_fn(require_auth)) // runs 2nd
.layer(from_fn(logger)); // runs 1st (outermost = listed last)
⚠️ Layer order gotcha: The last
.layer()you write is the outermost one, meaning it runs first on incoming requests. Last written = outermost = runs first.
Visually the stack looks like this:
Request comes in
|
v
[ logger middleware ] <- outermost, runs first
[ require_auth middleware ] <- inner, runs second
[ your handler ] <- innermost, runs last
[ require_auth middleware ] <- on the way back out
[ logger middleware ] <- on the way back out
|
v
Response goes out
10. A Complete Working Example
Here is a full Axum 0.8 server you can copy, paste, and run:
# Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing-subscriber = "0.3"
use axum::{
extract::Request,
http::{StatusCode, header},
middleware::{self, Next},
response::{Json, Response},
routing::get,
Router,
};
use serde::Serialize;
use std::time::Instant;
use tower_http::trace::TraceLayer;
// ── Middleware 1: Logger ────────────────────────────────────────────
async fn logger(req: Request, next: Next) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let start = Instant::now();
let response = next.run(req).await;
println!(
"{} {} → {} ({}ms)",
method, uri,
response.status(),
start.elapsed().as_millis()
);
response
}
// ── Middleware 2: Auth ──────────────────────────────────────────────
async fn require_auth(req: Request, next: Next) -> Result<Response, StatusCode> {
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
if token == "Bearer secret123" {
Ok(next.run(req).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
// ── Handlers ────────────────────────────────────────────────────────
#[derive(Serialize)]
struct Message { text: String }
async fn public_route() -> Json<Message> {
Json(Message { text: "Anyone can see this".to_string() })
}
async fn private_route() -> Json<Message> {
Json(Message { text: "You are authenticated!".to_string() })
}
// ── Main ────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let app = Router::new()
.route("/public", get(public_route))
.route("/private", get(private_route))
.route_layer(middleware::from_fn(require_auth)) // auth: route-level only
.layer(middleware::from_fn(logger)) // logger: global
.layer(TraceLayer::new_for_http()); // structured tracing on top
println!("Server running on http://localhost:3000");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Test it with curl:
# Public route — works without a token
curl http://localhost:3000/public
# Private route without token → 401
curl http://localhost:3000/private
# Private route with valid token → 200
curl -H "Authorization: Bearer secret123" http://localhost:3000/private
11. Common Beginner Mistakes
Mistake 1: Wrong Request import
// ❌ WRONG — generic type, compiler error in Axum 0.8
use axum::http::Request;
// ✅ CORRECT — pre-aliased with body type baked in
use axum::extract::Request;
Mistake 2: The Semicolon Trap
// ❌ WRONG — semicolon drops the value, nothing is returned
async fn my_middleware(req: Request, next: Next) -> Response {
next.run(req).await; // ← this semicolon is the bug!
}
// ✅ CORRECT — no semicolon, last expression is returned
async fn my_middleware(req: Request, next: Next) -> Response {
next.run(req).await // ← no semicolon = this value is returned
}
Mistake 3: Moving req Before You Are Done With It
// ❌ WRONG — Rust's borrow checker will stop you
async fn my_middleware(req: Request, next: Next) -> Response {
let method = req.method(); // borrows req
let response = next.run(req).await; // moves req — compiler error!
println!("{}", method);
response
}
// ✅ CORRECT — clone what you need before moving req
async fn my_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone(); // clone it first
let response = next.run(req).await; // now safe to move
println!("{}", method);
response
}
Mistake 4: Wrong Layer Order
// ❌ WRONG — if you want logger to run before auth:
let app = Router::new()
.route("/", get(handler))
.layer(from_fn(logger)) // you think this runs first...
.layer(from_fn(auth)); // but this actually runs first!
// ✅ CORRECT — last listed = outermost = runs first
let app = Router::new()
.route("/", get(handler))
.layer(from_fn(auth)) // runs second
.layer(from_fn(logger)); // runs first
12. Quick Reference
| You want to… | Use this |
|---|---|
Import Request correctly |
use axum::extract::Request |
| Log all requests easily |
TraceLayer::new_for_http() from tower-http 0.6
|
| Write custom middleware | from_fn(your_async_fn) |
| Read request method | req.method().clone() |
| Read request path | req.uri().clone() |
| Read the Authorization header | req.headers().get(header::AUTHORIZATION) |
| Pass request to handler | next.run(req).await |
Reject early (-> Response) |
return (StatusCode::UNAUTHORIZED, "msg").into_response() |
Reject early (-> Result) |
return Err(StatusCode::UNAUTHORIZED) |
| Wrap specific routes only | .route_layer(from_fn(...)) |
| Wrap everything including fallback | .layer(from_fn(...)) |
Summary
- Middleware is code that runs between a request arriving and your handler running
-
use axum::extract::Request— always import from here in Axum 0.8, never fromaxum::http -
from_fnturns your plainasync fninto a middleware layer — always use it as a beginner -
-> Result<Response, StatusCode>is the idiomatic return type for auth middleware per the official docs -
header::AUTHORIZATION— use the typed constant, not the raw string"authorization" -
next: Nextis the proceed button — callnext.run(req).awaitto pass the request forward -
Zone 1 / Zone 2 — code before
next.run()runs on the way in; code after runs on the way out -
.route_layer()wraps only your routes;.layer()wraps everything including the 404 fallback -
Layer order — the last
.layer()you write is the outermost one, so it runs first
What to Try Next
Now that you understand middleware, try these in order:
- Add
TraceLayerto a real project and watch the logs appear in your terminal - Write a middleware that adds a custom response header (hint:
response.headers_mut()) - Learn about
State— the recommended way to share app-wide data like a database pool - Explore
tower-http 0.6for ready-made middleware likeCorsLayerandCompressionLayer
Found this helpful? Drop a ❤️ and share it with someone learning Rust. Happy coding! 🦀
Top comments (0)