Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

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 myGitHub.

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 ourAppContext with methods to manage refresh tokens in Redis. Add the following implementation:

#[derive(Clone)]pubstructAppContext{pubconfig:Config,pubauth:AuthContext,pubdb:PgPool,pubredis:MultiplexedConnection,}implAppContext{pubasyncfnstore_refresh_token(&self,token_details:&TokenDetails)->Result<(),Report>{letmutconn=self.redis.clone();letkey=format!("refresh_token:{}",token_details.token_id);letvalue=serde_json::to_string(token_details)?;ifletSome(expires_in)=token_details.expires_in{letttl=(expires_in-chrono::Utc::now().timestamp())asu64;conn.set_ex(&key,&value,ttl).await?;}else{conn.set(&key,&value).await?;}Ok(())}pubasyncfnrevoke_refresh_token(&self,token_id:Uuid)->Result<(),Report>{letmutconn=self.redis.clone();letkey=format!("refresh_token:{}",token_id);conn.del(&key).await?;Ok(())}pubasyncfntry_from(config:&Config)->Result<Self,Report>{letdb=config.database().pool().await;letredis=config.redis().multiplexed_connection().await?;letauth=AuthContext{access:config.auth().access().try_into()?,refresh:config.auth().refresh().try_into()?,};Ok(Self{redis,db,auth,config:config.clone(),})}}
Enter fullscreen modeExit fullscreen mode

Note on implementation: I converted thetry_from method from aTryFrom<T> trait implementation to a regular async method. The trait version caused runtime panics becauseblock_on was being called in an async context during application startup.

Here's how token storage works:

  1. We serialize the entireTokenDetails struct to JSON and store it as a Redis string
  2. Theset_ex method automatically expires the token after its TTL (time-to-live), ensuring Redis cleans up stale tokens
  3. Theset method is a fallback that handles tokens without expiration (though this should rarely occur in practice)
  4. For explicit logout, we userevoke_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 amodel/ module and add auser.rs file:

usestd::borrow::Cow;useargon2::{Argon2,PasswordHash,PasswordVerifier,password_hash::{PasswordHasher,SaltString,rand_core::OsRng},};usechrono::{DateTime,FixedOffset,format::{DelayedFormat,StrftimeItems},};useserde::{Deserialize,Serialize};usesqlx::{Encode,Executor,Postgres,prelude::FromRow};useuuid::Uuid;usecrate::Result;#[derive(Debug,Deserialize,Serialize,Clone)]pubstructRegisterUser<'a>{email:Cow<'a,str>,name:Cow<'a,str>,password:Cow<'a,str>,}#[derive(Debug,Deserialize,Serialize,Clone)]pubstructLoginUser<'a>{email:Cow<'a,str>,password:Cow<'a,str>,}implLoginUser<'_>{pubfnemail(&self)->&str{&self.email}pubfnpassword(&self)->&str{&self.password}}#[derive(Debug,Deserialize,Clone,FromRow,Encode)]pubstructUser{id:i32,pid:Uuid,email:String,name:String,password:String,created_at:DateTime<FixedOffset>,updated_at:DateTime<FixedOffset>,}implUser{pubasyncfncreate_user<'e,C>(db:&C,new_user:&RegisterUser<'_>)->Result<Self>wherefor<'a>&'aC:Executor<'e,Database=Postgres>,{letuser=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)}pubasyncfnfind_by_email<'e,C>(db:&C,email:&str)->Result<Option<Self>>wherefor<'a>&'aC: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)}pubfnverify_password(&self,password:&str)->Result<()>{letpassword_hash=PasswordHash::new(&self.password).map_err(crate::Error::PasswordHash)?;Argon2::default().verify_password(password.as_bytes(),&password_hash).map_err(|err|matcherr{argon2::password_hash::Error::Password=>crate::Error::InvalidCredentials,_=>crate::Error::PasswordHash(err),})?;Ok(())}pubfnpid(&self)->Uuid{self.pid}pubfnemail(&self)->&str{&self.email}pubfnname(&self)->&str{&self.name}pubfnid(&self)->i32{self.id}pubfncreated_at(&self)->DelayedFormat<StrftimeItems<'_>>{self.created_at.format("%Y-%m-%d %H:%M")}}fnpassword_hash(plain_password:&str)->Result<String>{letargon2=Argon2::default();letsalt=SaltString::generate(&mutOsRng);lethash=argon2.hash_password(plain_password.as_bytes(),&salt).map_err(crate::Error::PasswordHash)?;Ok(hash.to_string())}
Enter fullscreen modeExit fullscreen mode

A note onCow<'a, str>: You'll notice we useCow<'a, str> instead ofString 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 actualUser struct from the database uses ownedString types since that data persists beyond the request lifecycle.

We use theargon2 crate for secure password hashing with randomly generated salts. Theverify_password method compares submitted passwords against the stored hash, converting any password mismatch into our customInvalidCredentials error.

Authentication Handlers

Now let's wire everything together with our HTTP handlers. Create acontrollers module and add anauth.rs file:

usestd::sync::Arc;useaxum::{Json,Router,body::Body,debug_handler,extract::State,http::{HeaderValue,StatusCode,header::{AUTHORIZATION,SET_COOKIE},},response::{IntoResponse,Response},routing::post,};useaxum_extra::extract::cookie;useserde_json::json;usecrate::{Result,context::AppContext,middlewares::AuthError,models::{LoginUser,RegisterUser,User},};#[debug_handler]asyncfnregister(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]asyncfnlogin(State(ctx):State<Arc<AppContext>>,Json(params):Json<LoginUser<'static>>,)->Result<Response>{letuser=User::find_by_email(&ctx.db,params.email()).await?.ok_or(crate::Error::Auth(AuthError::WrongCredentials))?;user.verify_password(params.password())?;// Issue access & refresh tokensletaccess_token=ctx.auth.access.generate_token(user.pid())?;letrefresh_token=ctx.auth.refresh.generate_token(user.pid())?;ctx.store_refresh_token(&refresh_token).await?;letaccess_token=access_token.token.unwrap();letrefresh_token=refresh_token.token.unwrap();letaccess_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);letrefresh_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);letmutres=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]asyncfncurrent(Extension(auth):Extension<TokenDetails>,State(ctx):State<Arc<AppContext>>,)->Result<Response>{letuser=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())}pubfnrouter(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 modeExit 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 theAuthorization 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 aSome(String) value, theOption 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 therouter function:

#[debug_handler]asyncfncurrent(Extension(auth):Extension<TokenDetails>,State(ctx):State<Arc<AppContext>>,)->Result<Response>{letuser=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 modeExit fullscreen mode

This handler demonstrates how middleware can enrich our request context. Notice theExtension(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 decodedTokenDetails 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 theAuthorization 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 validatedTokenDetails (containinguser_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 bothAuthLayer andRefreshLayer 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 therun method in your app struct:

implApp{pubasyncfnrun()->Result<()>{HookBuilder::new().theme(ifstd::io::stderr().is_terminal(){Theme::dark()}else{Theme::new()});letconfig=Config::load()?;config.logger().setup()?;letctx=Arc::new(AppContext::try_from(&config).await?);letrouter=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),);letlistener=TcpListener::bind(config.server().address()).await?;tracing::info!("Listening on {}",config.server().url());axum::serve(listener,router).await.map_err(Into::into)}}
Enter fullscreen modeExit fullscreen mode

The.nest() method mounts all our authentication routes under the/auth prefix. Since we're usingArc<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 modeExit fullscreen mode

You should receive this response:

{"message":"User created succesfully"}
Enter fullscreen modeExit 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 modeExit 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 modeExit fullscreen mode

Replaceaxum-auth_redis_data with your actual Redis volume name. You should see the serialized token details stored with a key likerefresh_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 theAuthorization 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 modeExit 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 modeExit fullscreen mode

Why both tokens are needed: The access token in theAuthorization header is validated first byAuthLayer. 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 modeExit fullscreen mode

You should receive a success response:

{"message":"Logout success"}
Enter fullscreen modeExit 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(1)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Some comments may only be visible to logged-in visitors.Sign in to view all comments.

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Full stack developer, utilising Rust & Javascript.
  • Joined

More fromSimon Bittok

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp