DEV Community

Syeed Talha
Syeed Talha

Posted on

JWT Auth Middleware in Axum 0.8 — A Beginner's Guide

If you've ever built a web API, you've probably asked yourself: "How do I make sure only the right people can access certain routes?" That's exactly what authentication middleware solves — and in this guide, you'll learn how to do it in Axum 0.8 using JSON Web Tokens (JWT).

By the end of this article, you'll understand:

  • What JWTs are and why they're useful
  • How middleware works in Axum 0.8
  • How to write a JWT auth middleware from scratch
  • How to protect routes and pass user data to your handlers

Let's get started.


What is a JWT?

A JSON Web Token (JWT) is a compact, self-contained string that carries information about a user. It looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

It has three parts separated by dots:

  1. Header — the algorithm used to sign the token (e.g. HS256)
  2. Payload — the actual data (e.g. user ID, expiry time)
  3. Signature — proves the token hasn't been tampered with

Here's the flow: a user logs in → your server creates a JWT and sends it back → the user includes that JWT in future requests → your middleware validates it before the request reaches your handler.


What is Middleware in Axum?

Think of middleware as a security guard standing between the internet and your handlers. Every request passes through the guard first. The guard can:

  • Let the request through (valid token → call next.run(req))
  • Block the request (invalid or missing token → return 401 Unauthorized)
  • Attach extra information to the request (e.g. the logged-in user's ID)

In Axum 0.8, you write middleware as a plain async function and wrap it with middleware::from_fn().


Project Setup

Start by creating a new project:

cargo new axum-jwt-demo
cd axum-jwt-demo
Enter fullscreen mode Exit fullscreen mode

Add these dependencies to your Cargo.toml:

[dependencies]
axum       = "0.8"
tokio      = { version = "1", features = ["full"] }
tower      = "0.5"
jsonwebtoken = "9"
serde      = { version = "1", features = ["derive"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

Here's what each crate does:

  • axum — the web framework
  • tokio — the async runtime axum runs on
  • tower — the middleware system axum uses under the hood
  • jsonwebtoken — encode and decode JWTs
  • serde / serde_json — serialize and deserialize the JWT payload (called "claims")

Step 1 — Define Your JWT Claims

A JWT's payload is called claims. Claims are just a Rust struct that you serialize into the token. At a minimum you want a user ID and an expiry time.

Create src/claims.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub: String,   // subject — usually the user ID
    pub exp: usize,    // expiry timestamp (Unix time)
}
Enter fullscreen mode Exit fullscreen mode

The field names sub and exp are standard JWT claim names. exp is especially important — the jsonwebtoken crate automatically rejects tokens where exp is in the past.


Step 2 — Create Helper Functions

You need two helpers: one to create a JWT (for your login route), and one to validate a JWT (for your middleware).

Create src/auth.rs:

use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::claims::Claims;

// In a real app, store this in an environment variable — never hardcode it!
const SECRET: &[u8] = b"my-super-secret-key";

/// Create a JWT for a given user ID.
/// Returns the token string on success.
pub fn create_token(user_id: &str) -> Result<String, jsonwebtoken::errors::Error> {
    let expiry = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs() as usize
        + 3600; // token expires in 1 hour

    let claims = Claims {
        sub: user_id.to_string(),
        exp: expiry,
    };

    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(SECRET),
    )
}

/// Validate a JWT string.
/// Returns the decoded Claims on success, or an error if the token is invalid/expired.
pub fn validate_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
    let token_data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(SECRET),
        &Validation::default(),
    )?;

    Ok(token_data.claims)
}
Enter fullscreen mode Exit fullscreen mode

Quick note on the secret key: In production, never hardcode this. Load it from an environment variable using the dotenvy crate. Anyone who knows the secret can forge tokens.


Step 3 — Write the Auth Middleware

Now for the heart of this guide — the middleware function.

Create src/middleware.rs:

use axum::{
    extract::Request,
    http::{header, StatusCode},
    middleware::Next,
    response::Response,
};

use crate::auth::validate_token;
use crate::claims::Claims;

pub async fn jwt_auth(
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {

    // 1. Get the Authorization header
    let auth_header = req
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|value| value.to_str().ok());

    // 2. Make sure it starts with "Bearer "
    let token = match auth_header {
        Some(header) if header.starts_with("Bearer ") => {
            &header["Bearer ".len()..] // slice off "Bearer " prefix
        }
        _ => {
            // No token or wrong format — reject with 401
            return Err(StatusCode::UNAUTHORIZED);
        }
    };

    // 3. Validate the token
    match validate_token(token) {
        Ok(claims) => {
            // 4. Attach the claims to the request so handlers can use them
            req.extensions_mut().insert(claims);
            // 5. Pass the request to the next layer (your handler)
            Ok(next.run(req).await)
        }
        Err(_) => {
            // Invalid or expired token — reject with 401
            Err(StatusCode::UNAUTHORIZED)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's walk through what happens:

  1. The middleware reads the Authorization header from the incoming request.
  2. It checks that the header starts with "Bearer " — the standard format for JWT tokens.
  3. It calls validate_token() to verify the signature and check the expiry.
  4. If valid, it inserts the decoded Claims into the request's extensions — a type-safe bag you can attach extra data to.
  5. It calls next.run(req) to pass the request onward to your handler.
  6. If anything fails, it immediately returns a 401 Unauthorized response. The handler never runs.

Step 4 — Write the Handlers

Now write a login handler (issues the token) and a protected handler (requires the token).

Create src/handlers.rs:

use axum::{extract::Extension, http::StatusCode, response::Json};
use serde_json::{json, Value};

use crate::auth::create_token;
use crate::claims::Claims;

/// Public route — anyone can call this to get a token.
pub async fn login() -> Result<Json<Value>, StatusCode> {
    // In a real app: verify username/password from the request body first!
    let user_id = "user-42";

    match create_token(user_id) {
        Ok(token) => Ok(Json(json!({ "token": token }))),
        Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

/// Protected route — only accessible with a valid JWT.
pub async fn dashboard(
    Extension(claims): Extension<Claims>,
) -> Json<Value> {
    // `claims` was attached by the middleware — no manual extraction needed
    Json(json!({
        "message": "Welcome to your dashboard!",
        "user_id": claims.sub,
    }))
}
Enter fullscreen mode Exit fullscreen mode

Notice how dashboard receives Extension(claims): Extension<Claims> — it's able to read the claims that the middleware attached to the request, without doing any JWT work itself. The middleware and handler are cleanly separated.


Step 5 — Wire Everything Together

Finally, set up the router in src/main.rs:

mod auth;
mod claims;
mod handlers;
mod middleware;

use axum::{middleware as axum_middleware, routing::get, Router};
use handlers::{dashboard, login};
use middleware::jwt_auth;

#[tokio::main]
async fn main() {
    // Protected routes — JWT middleware runs before each handler
    let protected = Router::new()
        .route("/dashboard", get(dashboard))
        .route_layer(axum_middleware::from_fn(jwt_auth));

    // Public routes — no middleware
    let app = Router::new()
        .route("/login", get(login))
        .merge(protected);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Two important things here:

  • route_layer() applies the middleware only to the routes defined above it in protected. The /login route is completely unaffected.
  • layer() (if used on the whole router) would apply the middleware to every route — including /login and even 404 responses. Use route_layer when you want surgical control.

Step 6 — Try It Out

Run the server:

cargo run
Enter fullscreen mode Exit fullscreen mode

Get a token (hit the login route):

curl http://localhost:3000/login
# Response: {"token":"eyJhbGci..."}
Enter fullscreen mode Exit fullscreen mode

Access the protected route with the token:

curl -H "Authorization: Bearer eyJhbGci..." http://localhost:3000/dashboard
# Response: {"message":"Welcome to your dashboard!","user_id":"user-42"}
Enter fullscreen mode Exit fullscreen mode

Try without a token:

curl http://localhost:3000/dashboard
# Response: 401 Unauthorized
Enter fullscreen mode Exit fullscreen mode

Try with a fake token:

curl -H "Authorization: Bearer fake-token" http://localhost:3000/dashboard
# Response: 401 Unauthorized
Enter fullscreen mode Exit fullscreen mode

Everything works exactly as expected.


Common Mistakes to Avoid

Hardcoding the secret key. Use environment variables in production. The dotenvy crate makes this easy — load your secret with std::env::var("JWT_SECRET") at startup.

Forgetting the expiry claim. If you don't set exp, anyone who gets hold of a token can use it forever. Always set a reasonable expiry (1 hour is a common choice for access tokens).

Using layer() instead of route_layer(). This accidentally protects your login route too, creating a chicken-and-egg problem where users can't log in because they need a token to get a token.

Not returning the right HTTP status codes. Use 401 Unauthorized when the token is missing or invalid. Use 403 Forbidden if the token is valid but the user doesn't have permission for that specific resource.


What's Next?

Now that you have the basics working, here are some natural next steps:

  • Refresh tokens — issue a short-lived access token and a long-lived refresh token so users stay logged in without re-entering their password.
  • Role-based access control — add a role field to your Claims struct and check it in the middleware or handler to differentiate between admin and regular users.
  • The governor crate — pair your auth middleware with rate limiting to protect your login route from brute-force attacks.
  • Secure your secret — use dotenvy to load JWT_SECRET from a .env file and never commit secrets to version control.

Authentication is one of those things that feels complex at first but clicks once you see the full picture. You've now got a solid foundation to build on.

Top comments (0)