How to add authentication to Axum API

Add authentication to Axum by creating a middleware that validates Bearer tokens in request headers before allowing access to protected routes.

The gatekeeper pattern

You built an API endpoint that returns the top scores for your game. You deploy it, and suddenly a script finds the URL and starts hammering it. Or you need to ensure only your Discord bot can update the leaderboard. You need a way to say, "Only requests with the secret key get in."

In Axum, you don't just slap a password on a route. You build a gatekeeper that checks every request before it reaches your handler. This keeps your business logic clean and ensures no unauthorized request ever touches your code. Authentication in Axum usually takes one of two shapes: middleware that acts as a filter, or a custom extractor that turns credentials into typed data.

Middleware vs extractors

Middleware runs before the route handler. It receives the request, inspects it, and decides whether to pass it along or reject it. Think of middleware like a bouncer at a club. The bouncer checks IDs at the door. If the ID is valid, the bouncer lets the person in. If not, the bouncer turns them away. The people inside the club never see the ID check; they just see the guests who passed.

Extractors are different. An extractor is a type that Axum tries to construct from the request. If the extraction fails, Axum rejects the request automatically. Extractors integrate tightly with Axum's type system. You define a struct, implement the FromRequest trait, and then use that struct as an argument in your handler. If the token is missing or invalid, the handler never runs. Extractors turn raw headers into safe Rust types. Your handlers get data, not strings.

Use middleware when you need to inspect or modify the request flow globally. Use extractors when you want to pass authenticated data directly to your handlers. Both approaches are idiomatic. The choice depends on whether you care about flow control or typed data.

Minimal middleware example

Middleware gives you explicit control over the request lifecycle. You can log requests, check tokens, modify headers, or measure latency. For a simple Bearer token check, middleware is often the cleanest path because it keeps the auth logic separate from your route handlers.

use axum::{
    extract::Extension,
    http::{Request, StatusCode},
    middleware::Next,
    response::Response,
    Router,
};
use std::sync::Arc;

/// Holds the secret token for validation.
/// Arc allows multiple handlers to share this state safely.
struct AppState {
    token: String,
}

/// Middleware that checks for a valid Bearer token.
/// Returns an error if the token is missing or invalid.
async fn auth_middleware(
    Extension(state): Extension<Arc<AppState>>,
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    // Extract the Authorization header.
    // Headers are case-insensitive, but the convention is "Authorization".
    let auth_header = req
        .headers()
        .get("Authorization")
        .and_then(|h| h.to_str().ok())
        .unwrap_or("");

    // The Bearer scheme requires the prefix "Bearer ".
    if !auth_header.starts_with("Bearer ") {
        return Err(StatusCode::UNAUTHORIZED);
    }

    // Strip the prefix to get the raw token.
    // unwrap is safe here because we checked the prefix.
    let token = auth_header.strip_prefix("Bearer ").unwrap();

    // Compare the token.
    // In production, use a constant-time comparison to prevent timing attacks.
    if token != state.token {
        return Err(StatusCode::UNAUTHORIZED);
    }

    // Token is valid. Pass the request to the next layer.
    // next.run returns a future that resolves to the response.
    Ok(next.run(req).await)
}

#[tokio::main]
async fn main() {
    // Wrap state in Arc for thread-safe sharing.
    let state = Arc::new(AppState {
        token: "super-secret-key".to_string(),
    });

    let app = Router::new()
        .route("/data", axum::routing::get(|| async { "You made it!" }))
        // Apply middleware to all routes in this router.
        .layer(axum::middleware::from_fn(auth_middleware))
        // Share state with all handlers and middleware.
        .layer(Extension(state));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Middleware is the gatekeeper. If the token is bad, the request never sees your business logic.

How the middleware runs

When a request hits /data, Axum doesn't call your handler immediately. It runs the middleware layer first. The auth_middleware function receives three arguments: the state, the request, and a Next object.

The Extension extractor pulls the AppState from the request. Axum injects this via the Extension layer you added to the router. The Request object contains the headers and body. The Next object is a future that represents the rest of the request chain.

You grab the Authorization header. If it's missing or doesn't start with Bearer , you return Err(StatusCode::UNAUTHORIZED). This stops the request dead. Axum converts that error into a 401 response and sends it back to the client. The handler never runs.

If the token matches, you call next.run(req).await. This hands control to the next layer or the route handler. The handler runs, produces a response, and that response bubbles back up through the middleware to the client. The middleware can inspect the response on the way back if needed, though this example just passes it through.

Why Arc? Axum runs handlers concurrently. Multiple requests might hit the middleware at the same time. Arc allows multiple owners of the state without copying the data. If you use a plain struct, the compiler will reject the code because the state can't be shared across threads safely. The Arc wrapper ensures thread-safe sharing with reference counting.

Realistic extractor pattern

In real apps, you rarely just check a token and stop. You usually want to know who made the request. You can attach the user info to the request so your handlers can use it. The cleanest way is to use a custom extractor that validates the token and yields the user data.

This approach moves the auth logic into the type system. Your handler signature declares that it requires a User. If the token is invalid, Axum rejects the request before the handler runs. You get compile-time guarantees that the handler always receives a valid user.

use axum::{
    extract::{FromRequest, Request, State},
    http::StatusCode,
    Json,
    Router,
};
use serde::Deserialize;
use std::sync::Arc;

/// The authenticated user data.
/// Clone is required because the extractor may need to copy the user.
#[derive(Clone, Deserialize)]
struct User {
    id: u32,
    name: String,
}

/// State containing the token-to-user mapping.
struct AppState {
    users: Arc<std::collections::HashMap<String, User>>,
}

/// Extractor that validates the token and returns the User.
/// Implements FromRequest to integrate with Axum's extraction system.
impl<S> FromRequest<S> for User
where
    AppState: Sync + Send + 'static,
    S: Sync + Send + 'static,
{
    type Rejection = StatusCode;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        // Extract the application state.
        // State::from_request accesses the state set by with_state.
        let State(state) = State::from_request(req, state)
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

        // Extract the Authorization header.
        let auth_header = req
            .headers()
            .get("Authorization")
            .and_then(|h| h.to_str().ok())
            .unwrap_or("");

        // Check for Bearer prefix.
        if !auth_header.starts_with("Bearer ") {
            return Err(StatusCode::UNAUTHORIZED);
        }

        let token = auth_header.strip_prefix("Bearer ").unwrap();

        // Look up the user by token.
        // Return 401 if the token doesn't match any user.
        state
            .users
            .get(token)
            .cloned()
            .ok_or(StatusCode::UNAUTHORIZED)
    }
}

#[tokio::main]
async fn main() {
    let mut users = std::collections::HashMap::new();
    users.insert(
        "token123".to_string(),
        User { id: 1, name: "Alice".to_string() },
    );

    let state = Arc::new(AppState { users });

    // with_state is the modern Axum way to share state.
    // It replaces Extension layers for application state.
    let app = Router::new()
        .route("/profile", axum::routing::get(get_profile))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

/// Handler that receives the authenticated User.
/// Axum calls the User extractor automatically.
async fn get_profile(user: User) -> Json<serde_json::Value> {
    Json(serde_json::json!({
        "id": user.id,
        "name": user.name,
    }))
}

Extractors turn raw headers into safe Rust types. Your handlers get data, not strings.

Pitfalls and compiler errors

Authentication code has sharp edges. A small mistake can crash your server or leak secrets.

Never unwrap a token from a header without checking. If the header is missing or malformed, unwrap panics. The server crashes. A missing token should return 401, not crash your server. Always use and_then and unwrap_or to handle missing headers gracefully.

Token comparison leaks timing information. The != operator short-circuits. If the first character is wrong, the comparison returns immediately. If the first character is right but the second is wrong, it takes slightly longer. An attacker can measure response times to guess the token character by character. Use the subtle crate for constant-time comparison. This ensures the comparison takes the same amount of time regardless of where the mismatch occurs.

State sharing requires Arc. If you try to move AppState into the router without Arc, you get E0382 (use of moved value). The router needs to share the state across multiple handlers and requests. Arc provides the necessary reference counting. If you forget Arc, the compiler stops you before you can ship broken code.

Trait bounds matter. The FromRequest implementation requires S: Sync + Send + 'static. These bounds ensure the state can be shared across threads safely. If you use a type that isn't Send, the compiler rejects the code with E0277 (trait bound not satisfied). This prevents data races at compile time. Trust the borrow checker here. It's protecting you from concurrency bugs that are nearly impossible to debug.

Convention aside: The community prefers Router::with_state over Extension layers for application state. with_state is type-safe and integrates better with extractors. Extension is still useful for ad-hoc data, but with_state is the standard for app-wide configuration and pools.

Choosing your approach

Pick the tool that matches the shape of your data. Middleware for flow control, extractors for typed data.

Use middleware when you need to inspect or modify the request before any handler runs, like logging or global auth checks.

Use a custom extractor when you want to pass authenticated data directly to your handlers as a typed argument.

Use State extraction when your handler needs access to shared resources like a database pool or config.

Reach for Arc<Mutex<T>> only when you need interior mutability across threads; prefer Arc<T> for read-only state.

Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Keep auth logic in safe Rust. Use crates like ring or subtle for crypto primitives.

Where to go next