DEV Community

Syeed Talha
Syeed Talha

Posted on

Axum Middleware: A Beginner's Complete Guide

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::Request or axum = "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:

  1. The doorman checks they are on the guest list → Auth middleware
  2. The host writes their name in the log book → Logging middleware
  3. The waiter checks they have not ordered too much already → Rate-limit middleware
  4. 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)
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

💡 from_fn is the magic adapter. You write a normal async function, and from_fn converts it into the Tower layer format that Axum expects. Always use from_fn as 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;
Enter fullscreen mode Exit fullscreen mode

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},
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

📌 The pivot point: next.run(req).await is 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 before next.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"
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

💡 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 nested match blocks. Axum knows how to convert a StatusCode error 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));
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 from axum::http
  • from_fn turns your plain async fn into 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: Next is the proceed button — call next.run(req).await to 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:

  1. Add TraceLayer to a real project and watch the logs appear in your terminal
  2. Write a middleware that adds a custom response header (hint: response.headers_mut())
  3. Learn about State — the recommended way to share app-wide data like a database pool
  4. Explore tower-http 0.6 for ready-made middleware like CorsLayer and CompressionLayer

Found this helpful? Drop a ❤️ and share it with someone learning Rust. Happy coding! 🦀

Top comments (0)