DEV Community

Syeed Talha
Syeed Talha

Posted on • Edited 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 = { version = "10.3.0", features = ["rust_crypto"] }
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.

&[u8] in Simple Terms

Think of it as a peek at some bytes — you're looking, not touching.

The pieces:

  • u8 → a number from 0–255 (one byte)
  • [u8] → a list of those numbers
  • & → "I'm just looking at it, not owning it"

The most common use:

let key: &[u8] = b"hello";
Enter fullscreen mode Exit fullscreen mode

b"hello" just means "give me the raw numbers behind this text"[104, 101, 108, 108, 111]

String vs Bytes — one line:

let a = "ABC";    // text
let b = b"ABC";   // same thing, but as numbers: [65, 66, 67]
Enter fullscreen mode Exit fullscreen mode

One-line summary:
&[u8] = "borrowed list of numbers" — you're pointing at bytes, not storing them.


Now lets understand encode(...) — What Each Argument Does

encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(SECRET),
    )
Enter fullscreen mode Exit fullscreen mode

This line creates the actual JWT token. Think of it as:

"Pack my data, sign it with a key, and give me a token string."

&Header::default()
The token format settings — just use defaults (algorithm: HS256).
Like saying "standard packaging, nothing special."

&claims
The actual data you want to store in the token — the user ID + expiry time.
This is the "what's inside the box."

&EncodingKey::from_secret(SECRET)
Your secret key used to sign the token, so nobody can fake it.
Like a wax seal on a letter — if it's broken, you know it was tampered with.
Now why the & on everything?
Just means "borrow it, don't consume it" — same & you already learned.

Full picture in one line:

encode( how to pack it,  what to pack,  your secret stamp )
       └─ Header ──────┘ └─ Claims ──┘ └─ EncodingKey ───┘
Enter fullscreen mode Exit fullscreen mode

Now lets understand validate_token Simply

    let token_data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(SECRET),
        &Validation::default(),
    )?;
    Ok(token_data.claims)
Enter fullscreen mode Exit fullscreen mode

This function checks if a token is real and trustworthy, then pulls out the data inside.

decode::<Claims>(...)
Open the token and try to read it as Claims (user ID + expiry).
The <Claims> part just says "I expect this type inside."

token
The raw JWT string the user sent you — "here's the token, check it."

&DecodingKey::from_secret(SECRET)
Your secret key to verify the signature.
Like checking the wax seal — "did I actually stamp this, or did someone fake it?"

&Validation::default()
The rules to check against — expiry time, algorithm, etc.
Like saying "run the standard checks, nothing custom."

? at the end
If anything fails (fake token, expired, wrong key) — stop and return the error immediately.

Ok(token_data.claims)
Everything passed ✅ — return just the useful part (user ID + expiry), not the whole token object.

Full picture:

decode( the token,  your secret key,   the rules )
        └─ input ─┘ └─ verify seal ──┘ └─ checks ┘
             ↓
        token_data.claims  →  just give me the data inside
Enter fullscreen mode Exit fullscreen mode

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.

Lets understand the Auth Header — Simply Explained

    let auth_header = req 
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok());
Enter fullscreen mode Exit fullscreen mode

This code looks for the Authorization header in the request — like checking if someone showed their ID card.

req.headers()
Get all headers from the incoming request.
Like "open the envelope and look at the labels."

A real request might carry headers like:

Authorization: Bearer eyJ...   // who you are (token)
Content-Type: application/json // what format the body is in
Accept-Language: en-US         // what language you prefer
User-Agent: Mozilla/5.0        // what browser/app you're using
Enter fullscreen mode Exit fullscreen mode

All of these live inside .headers() — a map of key → value pairs.

.get(header::AUTHORIZATION)
Find specifically the Authorization header.
Like "do you have an ID card?"

There are many header types you could .get() instead:

.get(header::CONTENT_TYPE)     // "what format is the body?"
.get(header::USER_AGENT)       // "what app sent this?"
.get(header::ACCEPT_LANGUAGE)  // "what language does the user want?"
Enter fullscreen mode Exit fullscreen mode

Returns Some(value) if found, None if missing. ✅ / ❌

.and_then(|v| v.to_str().ok())
If the header exists, try to convert it to a readable string.

  • and_then"if it exists, do this next step"
  • to_str().ok() → convert bytes to text, or return None if it fails

let auth_header
The result is either:

  • Some("Bearer eyJ...") → token found ✅
  • None → no header, no token ❌

Full picture:

req.headers()          // all headers
  .get(AUTHORIZATION)  // find the auth one
  .and_then(...)        // if found → convert to string
                              
                 Some("Bearer eyJ...") or None
Enter fullscreen mode Exit fullscreen mode

Lets understand the Extracting the Token — Simply Explained

let token = match auth_header {
        Some(header) if header.starts_with("Bearer ") => {
            &header["Bearer ".len()..]
        }
        _=>{
            return Err(StatusCode::UNAUTHORIZED);
        }
    };
Enter fullscreen mode Exit fullscreen mode

This code pulls out the actual token from the header value — like "ok you have an ID card, now let me read what's on it."

match auth_header
Check what's inside auth_header — like an if/else but cleaner.

Some(header) if header.starts_with("Bearer ")
Two checks in one line:

  • Some(header) → the header actually exists
  • if header.starts_with("Bearer ") → it looks like a real auth header

A valid header looks like this:

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
                ↑
           this part is the format we expect
Enter fullscreen mode Exit fullscreen mode

If both checks pass → go ahead ✅

&header["Bearer ".len()..]
Cut off the "Bearer " part and keep only the token.

"Bearer eyJhbGci..."
         
         start here  "eyJhbGci..."
Enter fullscreen mode Exit fullscreen mode
  • "Bearer ".len() → the length of "Bearer " which is 7
  • [7..]"give me everything after position 7"

_ => return Err(StatusCode::UNAUTHORIZED)
_ means everything else — no header, wrong format, anything unexpected.
Just say "401 Unauthorized, go away."

Full picture:

match auth_header
  ├── exists + starts with "Bearer " → strip it → just the token ✅
  └── anything else                  → 401 Unauthorized ❌
Enter fullscreen mode Exit fullscreen mode

Lets understand the Token validation part

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

This code checks if the token is real, then either lets the request through or blocks it.

match validate_token(token)
Run the token through the validator we saw earlier.
Returns either Ok(claims) or Err(...)"valid or not?"

Ok(claims) =>
Token is valid ✅ — now do two things:

req.extensions_mut().insert(claims)
Attach the user's data (claims) to the request.
Like "staple the user's ID info to the envelope" so any handler down the line can read it without checking the token again.

request  →  [token checked ✅]  →  claims attached  →  handler can use it
Enter fullscreen mode Exit fullscreen mode

Ok(next.run(req).await)
Pass the request forward to the actual handler.

  • next"whoever is waiting for this request"
  • .run(req)"send it through"
  • .await"wait for it to finish"

Like a security guard who checked your ID and now says "ok, you can go in." 🚪✅

Err(_) => Err(StatusCode::UNAUTHORIZED)
Token is fake, expired, or broken ❌
_ means "I don't care what the error is" — just reject with 401.

Full picture:

validate_token(token)
  ├── Ok   attach claims to request  pass to handler 
  └── Err  401 Unauthorized 
Enter fullscreen mode Exit fullscreen mode

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.


Lets understand the Extension(claims): Extension<Claims>

This is how the handler grabs the user's data that the middleware stapled to the request earlier.

The whole thing is just a function argument:

pub async fn dashboard(
    Extension(claims): Extension<Claims>,  // ← this is just one argument
)
Enter fullscreen mode Exit fullscreen mode

Axum automatically reads the request and fills this in for you. No manual work needed.

Extension<Claims> — the type
"I want to extract an Extension that contains Claims from the request."
Like saying "I know there's a stamped ID card on this envelope, give it to me."

Extension(claims) — the pattern
Unwrap the Extension wrapper and give me just the inner value as claims.

Think of it like a gift box:

Extension( claims )
└─ the box ─┘└─ what's inside ─┘
Enter fullscreen mode Exit fullscreen mode

You don't care about the box — you just want what's inside. ✅

Together:

Extension(claims): Extension<Claims>
//    ↑                   ↑
// unwrap it,         expect this type
// call it `claims`   in the request
Enter fullscreen mode Exit fullscreen mode

Why no manual extraction?
Because middleware already did the hard work:

middleware → validate token → insert(claims) → attached to request
handler   → Extension(claims)               → just grab it, done ✅
Enter fullscreen mode Exit fullscreen mode

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)