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
It has three parts separated by dots:
-
Header — the algorithm used to sign the token (e.g.
HS256) - Payload — the actual data (e.g. user ID, expiry time)
- 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
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"
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)
}
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)
}
Quick note on the secret key: In production, never hardcode this. Load it from an environment variable using the
dotenvycrate. 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";
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]
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),
)
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 ───┘
Now lets understand validate_token Simply
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(SECRET),
&Validation::default(),
)?;
Ok(token_data.claims)
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
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)
}
}
}
Let's walk through what happens:
- The middleware reads the
Authorizationheader from the incoming request. - It checks that the header starts with
"Bearer "— the standard format for JWT tokens. - It calls
validate_token()to verify the signature and check the expiry. - If valid, it inserts the decoded
Claimsinto the request's extensions — a type-safe bag you can attach extra data to. - It calls
next.run(req)to pass the request onward to your handler. - If anything fails, it immediately returns a
401 Unauthorizedresponse. 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());
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
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?"
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 returnNoneif 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
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);
}
};
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
If both checks pass → go ahead ✅
&header["Bearer ".len()..]
Cut off the "Bearer " part and keep only the token.
"Bearer eyJhbGci..."
↑
start here → "eyJhbGci..."
-
"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 ❌
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)
}
}
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
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 ❌
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,
}))
}
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
)
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 ─┘
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
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 ✅
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();
}
Two important things here:
-
route_layer()applies the middleware only to the routes defined above it inprotected. The/loginroute is completely unaffected. -
layer()(if used on the whole router) would apply the middleware to every route — including/loginand even 404 responses. Useroute_layerwhen you want surgical control.
Step 6 — Try It Out
Run the server:
cargo run
Get a token (hit the login route):
curl http://localhost:3000/login
# Response: {"token":"eyJhbGci..."}
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"}
Try without a token:
curl http://localhost:3000/dashboard
# Response: 401 Unauthorized
Try with a fake token:
curl -H "Authorization: Bearer fake-token" http://localhost:3000/dashboard
# Response: 401 Unauthorized
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
rolefield to yourClaimsstruct and check it in the middleware or handler to differentiate between admin and regular users. -
The
governorcrate — pair your auth middleware with rate limiting to protect your login route from brute-force attacks. -
Secure your secret — use
dotenvyto loadJWT_SECRETfrom a.envfile 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)