
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
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(),})}}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:
- We serialize the entire
TokenDetailsstruct to JSON and store it as a Redis string - The
set_exmethod automatically expires the token after its TTL (time-to-live), ensuring Redis cleans up stale tokens - The
setmethod is a fallback that handles tokens without expiration (though this should rarely occur in practice) - For explicit logout, we use
revoke_refresh_tokento 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())}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,¶ms).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())}Understanding the login flow:
- We verify the user's credentials against the database
- Generate both access and refresh tokens containing the user's UUID
- Store the refresh token details in Redis for later validation and revocation
- Send both tokens as cookies to the client:
- Access token:
http_only(false)so frontend JavaScript can read it and add it to theAuthorizationheader for API requests - Refresh token:
http_only(true)to prevent JavaScript access, protecting against XSS attacks
- Access token:
- 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())}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:
- Client sends request with access token in the
Authorizationheader AuthLayermiddleware validates the token and extracts the token details- If the access token is expired,
RefreshLayerattempts to refresh it using the refresh token from cookies - The validated
TokenDetails(containinguser_pid) is added to request extensions - 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)}}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/registerYou should receive this response:
{"message":"User created succesfully"}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/loginThe 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.rdbReplaceaxum-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/currentThis will return the authenticated user's profile:
{"name":"Test One","pid":"550e8400-e29b-41d4-a716-446655440000","email":"test1@mail.com"}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/logoutYou should receive a success response:
{"message":"Logout success"}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)
Some comments may only be visible to logged-in visitors.Sign in to view all comments.
For further actions, you may consider blocking this person and/orreporting abuse



