DEV Community

Cover image for Implementing JWT Authentication in Rust using Axum – Part 5
Simon Bittok
Simon Bittok

Posted on

Implementing JWT Authentication in Rust using Axum – Part 5

This is part 5 of this series, where I implement JWT authentication using the Axum framework in Rust.

Series

Part 4

Source Code

The entire source code is available on my GitHub.

Quick Recap

In the previous section, we learned how to pass state to handlers, create middleware using Tower, and generate JSON web tokens (JWTs). Now we'll put these pieces together to build a complete authentication system.

Introduction

In this chapter, we'll implement the core authentication flow: user registration and login. Our authentication strategy uses two types of tokens:

  • Access tokens (short-lived, 15 minutes) - sent in cookies and used for API authorization
  • Refresh tokens (long-lived, 1 month) - stored server-side in Redis and used to issue new access tokens

By storing refresh tokens in Redis, we gain the ability to explicitly revoke sessions, something that's impossible with stateless JWT tokens alone. When a user logs out, we simply delete their refresh token from Redis, immediately invalidating their session.

Token Storage and Retrieval

Let's extend our AppContext with methods to manage refresh tokens in Redis. Add the following implementation:

#[derive(Clone)]
pub struct AppContext {
    pub config: Config,
    pub auth: AuthContext,
    pub db: PgPool,
    pub redis: MultiplexedConnection,
}

impl AppContext {
    pub async fn store_refresh_token(&self, token_details: &TokenDetails) -> Result<(), Report> {
        let mut conn = self.redis.clone();
        let key = format!("refresh_token:{}", token_details.token_id);
        let value = serde_json::to_string(token_details)?;

        if let Some(expires_in) = token_details.expires_in {
            let ttl = (expires_in - chrono::Utc::now().timestamp()) as u64;
            conn.set_ex(&key, &value, ttl).await?;
        } else {
            conn.set(&key, &value).await?;
        }

        Ok(())
    }

    pub async fn revoke_refresh_token(&self, token_id: Uuid) -> Result<(), Report> {
        let mut conn = self.redis.clone();
        let key = format!("refresh_token:{}", token_id);

        conn.del(&key).await?;

        Ok(())
    }

    pub async fn try_from(config: &Config) -> Result<Self, Report> {
        let db = config.database().pool().await;
        let redis = config.redis().multiplexed_connection().await?;

        let auth = AuthContext {
            access: config.auth().access().try_into()?,
            refresh: config.auth().refresh().try_into()?,
        };

        Ok(Self {
            redis,
            db,
            auth,
            config: config.clone(),
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Note on implementation: I converted the try_from method from a TryFrom<T> trait implementation to a regular async method. The trait version caused runtime panics because block_on was being called in an async context during application startup.

Here's how token storage works:

  1. We serialize the entire TokenDetails struct to JSON and store it as a Redis string
  2. The set_ex method automatically expires the token after its TTL (time-to-live), ensuring Redis cleans up stale tokens
  3. The set method is a fallback that handles tokens without expiration (though this should rarely occur in practice)
  4. For explicit logout, we use revoke_refresh_token to immediately delete the token from Redis

Database Models

With our token storage ready, let's create the user model that will interact with our PostgreSQL database. Create a model/ module and add a user.rs file:

use std::borrow::Cow;

use argon2::{
    Argon2, PasswordHash, PasswordVerifier,
    password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
};
use chrono::{
    DateTime, FixedOffset,
    format::{DelayedFormat, StrftimeItems},
};
use serde::{Deserialize, Serialize};
use sqlx::{Encode, Executor, Postgres, prelude::FromRow};
use uuid::Uuid;

use crate::Result;

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RegisterUser<'a> {
    email: Cow<'a, str>,
    name: Cow<'a, str>,
    password: Cow<'a, str>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LoginUser<'a> {
    email: Cow<'a, str>,
    password: Cow<'a, str>,
}

impl LoginUser<'_> {
    pub fn email(&self) -> &str {
        &self.email
    }

    pub fn password(&self) -> &str {
        &self.password
    }
}

#[derive(Debug, Deserialize, Clone, FromRow, Encode)]
pub struct User {
    id: i32,
    pid: Uuid,
    email: String,
    name: String,
    password: String,
    created_at: DateTime<FixedOffset>,
    updated_at: DateTime<FixedOffset>,
}

impl User {
    pub async fn create_user<'e, C>(db: &C, new_user: &RegisterUser<'_>) -> Result<Self>
    where
        for<'a> &'a C: Executor<'e, Database = Postgres>,
    {
        let user = sqlx::query_as::<_, Self>(
            r"
           INSERT INTO users (email, name, password)
           VALUES ($1, $2, $3)
           RETURNING *
           ",
        )
        .bind(new_user.email.trim())
        .bind(new_user.name.trim())
        .bind(password_hash(&new_user.password)?)
        .fetch_one(db)
        .await?;
        Ok(user)
    }

    pub async fn find_by_email<'e, C>(db: &C, email: &str) -> Result<Option<Self>>
    where
        for<'a> &'a C: Executor<'e, Database = Postgres>,
    {
        sqlx::query_as(
            r"
            SELECT * FROM users WHERE email = $1
        ",
        )
        .bind(email.trim())
        .fetch_optional(db)
        .await
        .map_err(Into::into)
    }

    pub fn verify_password(&self, password: &str) -> Result<()> {
        let password_hash =
            PasswordHash::new(&self.password).map_err(crate::Error::PasswordHash)?;

        Argon2::default()
            .verify_password(password.as_bytes(), &password_hash)
            .map_err(|err| match err {
                argon2::password_hash::Error::Password => crate::Error::InvalidCredentials,
                _ => crate::Error::PasswordHash(err),
            })?;

        Ok(())
    }

    pub fn pid(&self) -> Uuid {
        self.pid
    }

    pub fn email(&self) -> &str {
        &self.email
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn id(&self) -> i32 {
        self.id
    }

    pub fn created_at(&self) -> DelayedFormat<StrftimeItems<'_>> {
        self.created_at.format("%Y-%m-%d %H:%M")
    }
}

fn password_hash(plain_password: &str) -> Result<String> {
    let argon2 = Argon2::default();
    let salt = SaltString::generate(&mut OsRng);

    let hash = argon2
        .hash_password(plain_password.as_bytes(), &salt)
        .map_err(crate::Error::PasswordHash)?;

    Ok(hash.to_string())
}
Enter fullscreen mode Exit fullscreen mode

A note on Cow<'a, str>: You'll notice we use Cow<'a, str> instead of String for the request structs. Cow (Clone on Write) is more memory-efficient because it can hold either a borrowed reference or an owned string. For deserialized JSON data that we only read from, this avoids unnecessary heap allocations. The actual User struct from the database uses owned String types since that data persists beyond the request lifecycle.

We use the argon2 crate for secure password hashing with randomly generated salts. The verify_password method compares submitted passwords against the stored hash, converting any password mismatch into our custom InvalidCredentials error.

Authentication Handlers

Now let's wire everything together with our HTTP handlers. Create a controllers module and add an auth.rs file:

use std::sync::Arc;

use axum::{
    Json, Router,
    body::Body,
    debug_handler,
    extract::State,
    http::{
        HeaderValue, StatusCode,
        header::{AUTHORIZATION, SET_COOKIE},
    },
    response::{IntoResponse, Response},
    routing::post,
};
use axum_extra::extract::cookie;
use serde_json::json;

use crate::{
    Result,
    context::AppContext,
    middlewares::AuthError,
    models::{LoginUser, RegisterUser, User},
};

#[debug_handler]
async fn register(
    State(ctx): State<Arc<AppContext>>,
    Json(params): Json<RegisterUser<'static>>,
) -> Result<Response> {
    let _new_user = User::create_user(&ctx.db, &params).await?;

    Ok((
        StatusCode::CREATED,
        Json(json! ({
            "message": "User created succesfully"
        })),
    )
        .into_response())
}

#[debug_handler]
async fn login(
    State(ctx): State<Arc<AppContext>>,
    Json(params): Json<LoginUser<'static>>,
) -> Result<Response> {
    let user = User::find_by_email(&ctx.db, params.email())
        .await?
        .ok_or(crate::Error::Auth(AuthError::WrongCredentials))?;

    user.verify_password(params.password())?;

    // Issue access & refresh tokens
    let access_token = ctx.auth.access.generate_token(user.pid())?;
    let refresh_token = ctx.auth.refresh.generate_token(user.pid())?;

    ctx.store_refresh_token(&refresh_token).await?;

    let access_token = access_token.token.unwrap();
    let refresh_token = refresh_token.token.unwrap();

    let access_cookie = cookie::Cookie::build(("access_token", &access_token))
        .path("/")
        .http_only(false)
        .max_age(time::Duration::seconds(ctx.auth.access.exp))
        .same_site(cookie::SameSite::Lax);

    let refresh_cookie = cookie::Cookie::build(("refresh_token", &refresh_token))
        .path("/")
        .http_only(true)
        .max_age(time::Duration::seconds(ctx.auth.refresh.exp))
        .same_site(cookie::SameSite::Lax);

    let mut res = Response::builder().status(StatusCode::OK).body(Body::from(
        json!({
            "access_token": &access_token,
            "name": user.name(),
            "created_at": user.created_at().to_string()
        })
        .to_string(),
    ))?;

    res.headers_mut().append(
        AUTHORIZATION,
        HeaderValue::from_str(access_token.as_str()).unwrap(),
    );
    res.headers_mut().append(
        SET_COOKIE,
        HeaderValue::from_str(access_cookie.to_string().as_str()).unwrap(),
    );
    res.headers_mut().append(
        SET_COOKIE,
        HeaderValue::from_str(refresh_cookie.to_string().as_str()).unwrap(),
    );

    Ok(res)
}

#[debug_handler]
async fn current(
    Extension(auth): Extension<TokenDetails>,
    State(ctx): State<Arc<AppContext>>,
) -> Result<Response> {
    let user = User::find_by_pid(&ctx.db, auth.user_pid).await?;
    Ok((
        StatusCode::OK,
        Json(json!({
            "name": user.name(),
            "pid": user.pid(),
            "email": user.email()
        })),
    )
        .into_response())
}

pub fn router(ctx: &Arc<AppContext>) -> Router {
    Router::new()
        .route("/register", post(register))
        .route("/login", post(login))
        .route(
            "/current",
            get(current)
                .layer(AuthLayer::new(ctx))
                .layer(RefreshLayer::new(ctx)),
        )
        .route(
            "/logout",
            post(logout)
                .layer(AuthLayer::new(ctx))
                .layer(RefreshLayer::new(ctx)),
        )
        .with_state(ctx.clone())
}
Enter fullscreen mode Exit fullscreen mode

Understanding the login flow:

  1. We verify the user's credentials against the database
  2. Generate both access and refresh tokens containing the user's UUID
  3. Store the refresh token details in Redis for later validation and revocation
  4. Send both tokens as cookies to the client:
    • Access token: http_only(false) so frontend JavaScript can read it and add it to the Authorization header for API requests
    • Refresh token: http_only(true) to prevent JavaScript access, protecting against XSS attacks
  5. Also include the access token in the response body for immediate use

The .unwrap() calls on the tokens are safe here because our token generation always produces a Some(String) value, the Option wrapper exists for other use cases in the token structure.

Fetching the Current User

We also need a way for authenticated users to retrieve their profile information. Add this handler above the router function:

#[debug_handler]
async fn current(
    Extension(auth): Extension<TokenDetails>,
    State(ctx): State<Arc<AppContext>>,
) -> Result<Response> {
    let user = User::find_by_pid(&ctx.db, auth.user_pid).await?;
    Ok((
        StatusCode::OK,
        Json(json!({
            "name": user.name(),
            "pid": user.pid(),
            "email": user.email()
        })),
    )
        .into_response())
}
Enter fullscreen mode Exit fullscreen mode

This handler demonstrates how middleware can enrich our request context. Notice the Extension(auth): Extension<TokenDetails> parameter, this isn't something we extract from the request directly. Instead, our refresh middleware validates the refresh token and injects the decoded TokenDetails into the request extensions, making the authenticated user's information available to the handler.

The flow works like this:

  1. Client sends request with access token in the Authorization header
  2. AuthLayer middleware validates the token and extracts the token details
  3. If the access token is expired, RefreshLayer attempts to refresh it using the refresh token from cookies
  4. The validated TokenDetails (containing user_pid) is added to request extensions
  5. Our handler extracts this data and fetches the full user profile from the database

Route protection with layered middleware: Notice how we apply both AuthLayer and RefreshLayer to the /current route. The order matters; RefreshLayer runs first to validate the refresh token, and if it access token is expired, issues a new one. AuthLayer validates the access token. This creates a seamless experience where users don't notice when their access tokens expire.

Registering Authentication Routes

Finally, let's mount our authentication routes in the main application. Update the run method in your app struct:

impl App {
    pub async fn run() -> Result<()> {
        HookBuilder::new().theme(if std::io::stderr().is_terminal() {
            Theme::dark()
        } else {
            Theme::new()
        });

        let config = Config::load()?;

        config.logger().setup()?;

        let ctx = Arc::new(AppContext::try_from(&config).await?);

        let router = Router::new()
            .route("/hello", get(|| async { "Hello from axum!" }))
            .nest("/auth", controllers::auth::router(&ctx))
            .layer(
                TraceLayer::new_for_http()
                    .make_span_with(middlewares::make_span_with)
                    .on_request(middlewares::on_request)
                    .on_response(middlewares::on_response)
                    .on_failure(middlewares::on_failure),
            );

        let listener = TcpListener::bind(config.server().address()).await?;

        tracing::info!("Listening on {}", config.server().url());

        axum::serve(listener, router).await.map_err(Into::into)
    }
}
Enter fullscreen mode Exit fullscreen mode

The .nest() method mounts all our authentication routes under the /auth prefix. Since we're using Arc<AppContext>, cloning it only increments a reference counter rather than copying the entire structure—perfect for sharing state across handlers.

Testing the Authentication Flow

Let's verify everything works by registering and logging in a user.

Registering a User

Run your application, then in another terminal execute:

curl -X POST -H "Content-Type: application/json" \
  -d '{ "email": "test1@mail.com", "name": "Test One", "password": "Password" }' \
  http://127.0.0.1:7150/auth/register
Enter fullscreen mode Exit fullscreen mode

You should receive this response:

{
  "message": "User created succesfully"
}
Enter fullscreen mode Exit fullscreen mode

Signing In

Now authenticate with the user you just created:

curl -X POST -H "Content-Type: application/json" \
  -d '{ "email": "test1@mail.com", "password": "Password" }' \
  http://127.0.0.1:7150/auth/login
Enter fullscreen mode Exit fullscreen mode

The response will include an access token, the user's name, and their account creation timestamp. Behind the scenes, the refresh token has been securely stored in Redis.

To verify the refresh token is in Redis, you can inspect your Redis volume:

sudo cat /var/lib/docker/volumes/axum-auth_redis_data/_data/dump.rdb
Enter fullscreen mode Exit fullscreen mode

Replace axum-auth_redis_data with your actual Redis volume name. You should see the serialized token details stored with a key like refresh_token:<uuid>.

Testing the Current User Endpoint

With an active session, you can now fetch the authenticated user's profile. When making the request, you need to include both the Authorization header and the refresh token cookie:

curl -X GET \
  -H "Authorization: Bearer <your_access_token>" \
  -H "Cookie: refresh_token=<your_refresh_token>" \
  http://127.0.0.1:7150/auth/current
Enter fullscreen mode Exit fullscreen mode

This will return the authenticated user's profile:

{
  "name": "Test One",
  "pid": "550e8400-e29b-41d4-a716-446655440000",
  "email": "test1@mail.com"
}
Enter fullscreen mode Exit fullscreen mode

Why both tokens are needed: The access token in the Authorization header is validated first by AuthLayer. If it has expired (after 15 minutes), RefreshLayer checks for the refresh token cookie, validates it against Redis, and issues a new access token. The refresh token cookie is essential for this automatic token renewal to work. Without it, an expired access token would simply result in an authentication error.

Testing Logout

To test the logout functionality, make sure you're authenticated (you should have both tokens from the login response), then execute:

curl -X POST \
  -H "Authorization: Bearer <your_access_token>" \
  -H "Cookie: refresh_token=<your_refresh_token>" \
  http://127.0.0.1:7150/auth/logout
Enter fullscreen mode Exit fullscreen mode

You should receive a success response:

{
  "message": "Logout success"
}
Enter fullscreen mode Exit fullscreen mode

After logout, if you try to access the /current endpoint again with the same tokens, you'll receive an authentication error. The refresh token has been deleted from Redis, so even though you still have the cookie, it can no longer be used to generate new access tokens. The old access token will continue to work until it expires (within 15 minutes), but this short window is an acceptable trade-off for the performance benefits of stateless JWT validation.


We now have a working authentication system with user registration and login! The access tokens enable short-lived API access, while refresh tokens stored in Redis give us explicit control over session revocation.

This Series

Part 1: Project Setup & Configuration
Part 2: Implementing Logging
Part 3: Database Setup with SQLx and PostgreSQL.
Part 4: JWTs & Middlewares
Part 5: Creating & Authenticating Users (You are here)

Top comments (0)