Building a non-trivial web application with Rust can be fairly straightforward. However, when things become complex and require features like authentication, middleware, and more, that’s where Axum shines. Axum makes it a lot easier to build complex web API authentication systems.
In this step-by-step guide, we’ll build a JWT authentication API using Rust and the Axum framework. We’ll cover everything from building the authentication endpoints to JWT middleware and protected routes.
Let’s jump right in.
Let’s start by installing Rust, Axum, and all the necessary dependencies. Run the following commands to install Rust if you don’t already have Rust installed:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
The above command requires internet to do. It’ll download and set up Rust and all the tools needed for Rust development, except for your code editor 🙂
Next, run the command below to create a new Rust project and install all the dependencies necessary for this project:
cargo new rust-auth && cd rust-auth && cargo add tokio --features full && cargo add [email protected] --features derive && cargo add [email protected] --features serde && cargo add [email protected] [email protected] [email protected] [email protected]
The result should generate a directory like this:
. ├── Cargo.lock ├── Cargo.toml └── src └── main.rs
And the Cargo.toml
file should look like this:
[package] name = "rust-auth" version = "0.1.0" edition = "2021" [dependencies] axum = "0.7.5" bcrypt = "0.15.1" chrono = { version = "0.4.34", features = ["serde"] } jsonwebtoken = "9.3.0" serde = { version = "1.0.195", features = ["derive"] } serde_json = "1.0.95" tokio = { version = "1.37.0", features = ["full"] }
Here is a quick rundown of the dependencies we added and why we added each one:
Now that we have our setup completed, let’s create the relevant endpoints for our project.
We’ll have a route for the user to login, as well as a protected route to demonstrate how to protect our endpoints using the Axum middleware system.
Before we proceed with that, let’s create the web server with Tokio and Axum in the main.rs
file. First off, here’s the basic server anatomy:
For our specific project, copy the code below and replace your existing code in the main.rs
file with it:
use axum; use tokio::net::TcpListener; mod routes; #[tokio::main] async fn main() { let listener = TcpListener::bind("127.0.0.1:8080") .await .expect("Unable to connect to the server"); let app = routes::app().await; axum::serve(listener, app) .await .expect("Error serving application"); println!("Listening on {}", listener.local_addr().unwrap() ); }
The above code uses Tokio’s TCP listener bound to the address 127.0.0.1:8080
and then uses Axum to serve the web app. It also imports the routes definition which is where will set our focus now.
Let’s define the different routes we’ll use for our authentication. Basically, the flow will enable the user to:
/signin
route)/protected/
route)In that case, we’ll have two endpoints — let’s create them! Create a routes.rs
file in the src/
directory and add the following code in it:
use axum::{ middleware, routing::{get, post}, Router, }; use crate::{auth, services}; pub async fn app() -> Router { Router::new() .route("/signin", post(auth::sign_in)) .route( "/protected/", get(services::hello).layer(middleware::from_fn(auth::authorize)), ) }
The code above contains the two routes definition with their handlers. Notice that there is a middleware in the /protected
endpoint (auth::authorize) — we’ll take a look at that in a minute.
We’ve imported the auth
and hello
services — that’s were we’ll implement the handlers. Let’s create them:
Create a services.rs
file and add the code below to create the hello
handler:
use crate::auth::CurrentUser; #[derive(Serialize, Deserialize)] struct UserResponse { email: String, first_name: String, last_name: String } pub async fn hello(Extension(currentUser): Extension<CurrentUser>) -> impl IntoResponse { Json(UserResponse { email: currentUser.email, first_name: currentUser.first_name, last_name: currentUser.last_name }) }
The hello
handler returns the logged in user’s profile information. When we call the protected route with the user JWT token, the server will return the user information like so:
The next service is auth
. This service contains all the implementations for our JWT authentication. This is where you’ll need to pay closer attention 😁
Create the auth.rs
file in the src/
directory. Then, add the code to sign a user in with their username and password as shown below:
use axum::{ body::Body, response::IntoResponse, extract::{Request, Json}, http, http::{Response, StatusCode}, middleware::Next, }; use bcrypt::{hash, verify, DEFAULT_COST}; use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation}; use serde::{Deserialize, Serialize}; use serde_json::json; #[derive(Serialize, Deserialize)] // Define a structure for holding claims data used in JWT tokens pub struct Claims { pub exp: usize, // Expiry time of the token pub iat: usize, // Issued at time of the token pub email: String, // Email associated with the token } // Define a structure for holding sign-in data #[derive(Deserialize)] pub struct SignInData { pub email: String, // Email entered during sign-in pub password: String, // Password entered during sign-in } // Function to handle sign-in requests pub async fn sign_in( Json(user_data): Json<SignInData>, // JSON payload containing sign-in data ) -> Result<Json<String>, StatusCode> { // Return type is a JSON-wrapped string or an HTTP status code // Attempt to retrieve user information based on the provided email let user = match retrieve_user_by_email(&user_data.email) { Some(user) => user, // User found, proceed with authentication None => return Err(StatusCode::UNAUTHORIZED), // User not found, return unauthorized status }; // Verify the password provided against the stored hash if !verify_password(&user_data.password, &user.password_hash) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? // Handle bcrypt errors { return Err(StatusCode::UNAUTHORIZED); // Password verification failed, return unauthorized status } // Generate a JWT token for the authenticated user let token = encode_jwt(user.email) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Handle JWT encoding errors // Return the token as a JSON-wrapped string Ok(Json(token)) } #[derive(Clone)] pub struct CurrentUser { pub email: String, pub first_name: String, pub last_name: String, pub password_hash: String } // Function to simulate retrieving user data from a database based on email fn retrieve_user_by_email(email: &str) -> Option<CurrentUser> { // For demonstration purposes, a hardcoded user is returned based on the provided email let current_user: CurrentUser = CurrentUser { email: "[email protected]".to_string(), first_name: "Eze".to_string(), last_name: "Sunday".to_string(), password_hash: "$2b$12$Gwf0uvxH3L7JLfo0CC/NCOoijK2vQ/wbgP.LeNup8vj6gg31IiFkm".to_string() }; Some(current_user) // Return the hardcoded user }
Although the code is a bit long, it is heavily commented to make it easy to understand and follow along. Now, we are going to explain every part of it.
First, we created the Claims
and SignInData
data struct.
The Claims
is what we expect to be encoded in the JWT token. We want the expiry, issue date/time, and email to be in Claims
. We expect the user to send their email address and password in exchange for the JWT token:
// Define a structure for holding claims data used in JWT tokens pub struct Claims { pub exp: usize, // Expiry time of the token pub iat: usize, // Issued at time of the token pub email: String, // Email associated with the token }
The SignInData
struct represents that data, as shown below in this extracted code from the previous code:
// Define a structure for holding sign-in data #[derive(Deserialize)] pub struct SignInData { pub email: String, // Email entered during sign-in pub password: String, // Password entered during sign-in }
Next, we get into the sign-in function. The sign-in function accepts a JSON object as the request body and returns a JSON or StatusCode as shown below:
// Function to handle sign-in requests pub async fn sign_in( Json(user_data): Json<SignInData>, // JSON payload containing sign-in data ) -> Result<Json<String>, StatusCode> {
In the sign-in function, we attempt to get the users information from the database based on their email address they provided. If it does not exist, we don’t proceed with the login.
If it does exists, we want to verify the password the user sent with the hashed password in the database. For simplicity, we simulated the database user retrieval with the retrieve_user_by_email
function below:
// Function to simulate retrieving user data from a database based on email fn retrieve_user_by_email(email: &str) -> Option<CurrentUser> { // For demonstration purposes, a hardcoded user is returned based on the provided email let current_user: CurrentUser = CurrentUser { email: "[email protected]".to_string(), first_name: "Eze".to_string(), last_name: "Sunday".to_string(), password_hash: "$2b$12$Gwf0uvxH3L7JLfo0CC/NCOoijK2vQ/wbgP.LeNup8vj6gg31IiFkm".to_string() // the plain password hashed to this is "okon" without the quotes. }; Some(current_user) // Return the hardcoded user }
We used bcrypt password hashing to generate the password for this example. The password in the above example is okon
. Also, below are the functions for hashing and verifying a password. Add them to the auth.rs
file:
pub fn verify_password(password: &str, hash: &str) -> Result<bool, bcrypt::BcryptError> { verify(password, hash) } pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> { let hash = hash(password, DEFAULT_COST)?; Ok(hash) }
Finally, we generate the JWT token and encode the user’s email into it:
let token = encode_jwt(user.email) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Handle JWT encoding errors
But the encode_jwt
function isn’t created yet, so we need to create it. Add the encode_jwt
function to the auth.rs
file:
pub fn encode_jwt(email: String) -> Result<String, StatusCode> { let secret: String = "randomStringTypicallyFromEnv".to_string(); let now = Utc::now(); let expire: chrono::TimeDelta = Duration::hours(24); let exp: usize = (now + expire).timestamp() as usize; let iat: usize = now.timestamp() as usize; let claim = Claims { iat, exp, email }; encode( &Header::default(), &claim, &EncodingKey::from_secret(secret.as_ref()), ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) }
This function above uses the jwt
library to generate a valid login authentication token. The function accepts email addresses and you can add other information into the jwt
encoding as you need.
We’ll also need to decode the JWT when we start working on the the middleware, so, let’s create the decode_jwt
function. Include the decode_jwt
code below in the auth.rs
file:
pub fn decode_jwt(jwt_token: String) -> Result<TokenData<Claims>, StatusCode> { let secret = "randomStringTypicallyFromEnv".to_string(); let result: Result<TokenData<Claims>, StatusCode> = decode( &jwt_token, &DecodingKey::from_secret(secret.as_ref()), &Validation::default(), ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR); result }
Make sure the DecodingKey
algorithm matches for both the encoding and decoding functions. The default algorithm is HS256; if you choose to use the default, you should also use the same secret for both the encode_jwt
and decode_jwt
functions. You can also use the RSA encryption algorithm if you need to. Here is an example of how you’d use the RSA encryption algorithm for the encoding:
let result = encode(&Header::new(Algorithm::RS256), &my_claims, &EncodingKey::from_rsa_pem(include_bytes!("privkey.pem"))?)?;
Here is decoding with the RSA encryption algorithm:
let result = decode::<Claims>(&jwt_token, &DecodingKey::from_rsa_components(jwk["n"], jwk["e"]), &Validation::new(Algorithm::RS256))?;
Now that we’ve got the sign-in function in order, let’s take a closer look at the middleware function to protect our routes. Add a new function by copying and pasting the code below into the auth.rs
file:
pub async fn authorization_middleware(mut req: Request, next: Next) -> Result<Response<Body>, AuthError> { let auth_header = req.headers_mut().get(http::header::AUTHORIZATION); let auth_header = match auth_header { Some(header) => header.to_str().map_err(|_| AuthError { message: "Empty header is not allowed".to_string(), status_code: StatusCode::FORBIDDEN })?, None => return Err(AuthError { message: "Please add the JWT token to the header".to_string(), status_code: StatusCode::FORBIDDEN }), }; let mut header = auth_header.split_whitespace(); let (bearer, token) = (header.next(), header.next()); let token_data = match decode_jwt(token.unwrap().to_string()) { Ok(data) => data, Err(_) => return Err(AuthError { message: "Unable to decode token".to_string(), status_code: StatusCode::UNAUTHORIZED }), }; // Fetch the user details from the database let current_user = match retrieve_user_by_email(&token_data.claims.email) { Some(user) => user, None => return Err(AuthError { message: "You are not an authorized user".to_string(), status_code: StatusCode::UNAUTHORIZED }), }; req.extensions_mut().insert(current_user); Ok(next.run(req).await) }
Let’s explain the code. The function takes a mutable Request
and Next
objects as arguments and returns a Response
or Error
result. This is a typical Axum middleware function signature. The Next
object represents the next middleware or handler in the chain that should be called after this middleware.
Now, we’ll grab the header content and attempt to extract the token that was passed to it by the client:
let auth_header = req.headers_mut().get(http::header::AUTHORIZATION);
If the token exists, we go ahead to decode the token, get the user’s email from it, and query the database to fetch the user’s profile. Then, we pass the user information to app extensions for the handler that will be using the middleware and handling the request.
Remember the protected
endpoint and the corresponding hello
handler?
.route("/protected/",get(services::hello).layer(middleware::from_fn(auth::authorize_middleware)),)
The hello
handler takes in an extension as an argument with the type CurrentUser
type and returns a type of impl IntoResponse
which is the typical return type of all Axum handlers.
pub async fn hello(Extension(currentUser): Extension<CurrentUser>) -> impl IntoResponse { ... }
Next, let’s test our implementation with Postman. If you’d love to clone the entire project and dive deep into it, or just test it out, you can clone it from GitHub by running the command below:
git clone https://github.com/ezesundayeze/axum--auth
We’ve have developed two endpoints: the login endpoint and the protected endpoint. Let’s start by running the server by running the command below:
cargo run
And then signing in with our username and password:
The login returns our JWT token as expected. Next, we’ll copy the JWT token and use it to access the protected endpoint but before that, if we make the API call without the token, we’ll get an error:
Add the token. Now we can access the protected API properly:
We’ve come a long way! I hope you enjoyed reading the walkthrough and following along (if you did follow along).
In this tutorial, we covered how to build a basic JWT authentication system from start to finish, noting all the key parts. From setting up the routes, handlers, and the middleware system, I hope this will help you bootstrap your Rust project easily. You can find the full project on GitHub.
Happy hacking!
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.