How to Implement Authentication in Rust (JWT, Sessions)

Web
Use the `jsonwebtoken` crate with Axum's `FromRequestParts` extractor to validate tokens in the `Authorization` header.

How to Implement Authentication in Rust (JWT, Sessions)

You're building a todo app. You have a route /todos. You want to make sure only the owner can delete their todos. You try to check a header in the handler, but the compiler screams about lifetimes and async contexts. You realize authentication in Rust isn't just a middleware plugin you slap on top. It's a type system puzzle. The compiler can help you enforce authentication at the signature level, so your handler code never runs unless the user is verified.

Authentication as a type

Authentication is the bouncer at the door. Authorization is the list of tables you're allowed to sit at. In Rust, the best auth flows turn the bouncer's check into a type. You don't just get a boolean saying "yes, you're logged in". You get a value that the compiler guarantees exists for the rest of the request.

If the token is bad, the handler never runs. The type system enforces the gate. When you declare a handler argument as Claims, you are making a contract. The compiler verifies that Claims can be constructed from the request parts. If the construction fails, the handler is unreachable. This eliminates an entire class of bugs where a developer forgets to check authentication before accessing protected data. In JavaScript, you might have a middleware that sets req.user, but nothing stops you from writing a route that ignores the middleware or accesses req.user before it's set. Rust forces the check to happen before the handler logic runs.

Trust the type. If Claims is in your handler, the compiler proved the token is valid.

The JWT Extractor

The idiomatic way to handle JWTs in Axum is to implement FromRequestParts for your claims struct. This turns your claims into an extractor. Axum runs the extraction logic before calling your handler. If extraction returns an error, Axum converts it to a response and skips the handler.

Convention aside: The community prefers axum_extra for header extraction. Writing raw header parsing logic is error-prone and verbose. TypedHeader wraps the standard library's Header trait and gives you a strongly typed value. It also handles case-insensitivity and multiple values correctly. Stick to axum_extra for headers; it saves you from reinventing the wheel and hitting edge cases.

Convention aside: Use LazyLock for static configuration like secret keys. LazyLock ensures the key is initialized exactly once, thread-safely, on first access. It's part of the standard library now, replacing older crates like once_cell. It keeps your statics clean and avoids the overhead of re-creating the key on every request.

use axum::{
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    Router,
};
use axum_extra::{
    headers::{authorization::Bearer, Authorization},
    TypedHeader,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;

/// Secret key for decoding JWTs. Initialized once on first access.
static SECRET: LazyLock<DecodingKey> = LazyLock::new(|| {
    DecodingKey::from_secret("my-secret-key".as_bytes())
});

/// Claims extracted from the JWT payload.
/// Derives Serialize/Deserialize for jsonwebtoken compatibility.
#[derive(Debug, Deserialize, Serialize)]
struct Claims {
    /// Subject identifier, usually the user ID.
    sub: String,
    /// Expiration timestamp. Required if validate_exp is true.
    exp: i64,
}

impl<S> FromRequestParts<S> for Claims
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    /// Extracts Claims from the Authorization header.
    /// Returns AuthError if the header is missing or the token is invalid.
    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        // Extract the Bearer token from the Authorization header.
        // TypedHeader handles parsing the header string into a strong type.
        let TypedHeader(Authorization(bearer)) = parts
            .extract::<TypedHeader<Authorization<Bearer>>>()
            .await
            .map_err(|_| AuthError::InvalidToken)?;

        let mut validation = Validation::default();
        validation.validate_exp = true; // Enforce expiration checks.

        // Decode and verify the token signature and claims.
        // decode returns an error if signature is invalid or claims are malformed.
        let token_data = decode::<Claims>(bearer.token(), &SECRET, &validation)
            .map_err(|_| AuthError::InvalidToken)?;

        Ok(token_data.claims)
    }
}

/// Errors that can occur during authentication.
#[derive(Debug)]
enum AuthError {
    InvalidToken,
}

impl IntoResponse for AuthError {
    /// Converts AuthError into an HTTP 401 response.
    fn into_response(self) -> Response {
        (StatusCode::UNAUTHORIZED, "Invalid token").into_response()
    }
}

Why FromRequestParts matters

You might see FromRequest mentioned in other contexts. Use FromRequestParts for authentication. FromRequest consumes the entire Request, including the body. If you use FromRequest for auth, you drain the body. Any subsequent extractor, like Json, will find an empty body and fail. FromRequestParts only touches the headers and method. It leaves the body intact for other extractors. This allows you to combine auth with JSON parsing seamlessly.

The extraction flow is linear. Axum calls from_request_parts. The method extracts the Authorization header using TypedHeader. It decodes the bearer token. It validates the signature and expiration. If everything passes, it returns Claims. The handler receives the Claims struct as a normal argument. Inside the handler, claims.sub is a String. It exists. It's valid. The compiler enforced the preconditions.

Pitfalls and compiler errors

If you forget #[derive(Deserialize)] on Claims, the compiler rejects the decode call with E0277 (trait bound not satisfied). The JSON library needs to know how to turn the token payload into your struct. Add the derive macro and the error disappears.

If your token lacks an exp field and validate_exp is true, decode returns an error. The extractor catches it and returns AuthError. Axum converts that to a 401 response. The handler never runs. Make sure your token generation logic includes the expiration claim.

If your Claims struct contains a field that isn't Send, the compiler will reject the extractor with E0277. Web servers run handlers on thread pools, so types must be Send. If you accidentally put an Rc or a raw pointer in your claims, you'll hit this bound. Keep your claims simple: strings, numbers, and enums. Derive Serialize and Deserialize to satisfy the JSON requirements.

If you return a String where Response is expected in a handler, you get E0308 (mismatched types). Axum handlers must return something that implements IntoResponse. Use String, Json, or a tuple with status codes. The IntoResponse impl on AuthError handles the conversion for errors.

Treat JWT payloads as public postcards. Base64 encoding is not encryption. Anyone with the token can read the claims. Never put passwords or sensitive secrets in the JWT payload.

Realistic handler usage

With the extractor in place, your handlers become clean. You don't see header parsing. You don't see signature verification. The type system moved all that complexity to the extractor.

use axum::Router;

/// Handles protected resources.
/// The compiler guarantees claims is valid because FromRequestParts succeeded.
async fn protected_handler(claims: Claims) -> String {
    // Access claims directly. No Option, no Result, no None checks.
    format!("Hello user {}", claims.sub)
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/protected", axum::routing::get(protected_handler));

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

Decision: JWT vs Sessions

Authentication models trade off state for flexibility. Pick the model that matches your revocation needs and deployment architecture.

Use JWT when you need stateless authentication across multiple services or a CDN. The token carries all the data, so the server doesn't need a database lookup for every request. Use JWT when your frontend is a separate SPA or mobile app that stores the token in memory or secure storage. Use sessions when you need to revoke access immediately. A session ID is just a handle; the server holds the state. If you delete the session in Redis, the user is logged out instantly. Use sessions when you are building a traditional server-rendered web app where cookies handle the transport automatically. Use tower-sessions when you want a flexible backend for session storage, swapping between memory, Redis, or SQL with minimal code changes.

Stateless is convenient until you need to revoke access. Pick the model that matches your threat model.

Where to go next