DEV Community

Cover image for Using Rust and Axum to build a JWT authentication API
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Using Rust and Axum to build a JWT authentication API

Written by Eze Sunday✏️

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.

Setting up our Rust and Axum project

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
Enter fullscreen mode Exit fullscreen mode

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 serde@1.0.195 --features derive && cargo add chrono@0.4.34 --features serde && cargo add axum@0.7.5  jsonwebtoken@9.3.0 bcrypt@0.15.1 serde_json@1.0.95
Enter fullscreen mode Exit fullscreen mode

The result should generate a directory like this:

.
├── Cargo.lock
├── Cargo.toml
└── src
    └── main.rs
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

Here is a quick rundown of the dependencies we added and why we added each one:

  • Axum: This is the core Axum Web framework we’ll use for this project
  • Tokio: We’ll use the Rust Tokio runtime to write asynchronous functions
  • Serde: We’ll use Serde for our serialization and deserialization needs
  • Chrono: We’ll also need the date and time library for different things in our application. Specifically, we’ll use it to generate our API token expiry time
  • JSON Web Token (jsonwebtoken): The jsonwebtoken library will help us generate and verify JSON Web Tokens
  • BCrypt: Since we’ll be integrating password hashing, we’ll use BCrypt password hashing function for that
  • Serde JSON: We'll eventually need to return the API response to the client via a REST API. So, we'll use the Serde JSON library to convert from other data structures to JSON and return the response

Now that we have our setup completed, let’s create the relevant endpoints for our project.

Authentication endpoints using Axum middleware

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.

Tokio and Axum server setup

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: Example Of A Simple Server Set Up Using Tokio And Axum In A Rust Project 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() );
}
Enter fullscreen mode Exit fullscreen mode

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.

Authentication routes

Let’s define the different routes we’ll use for our authentication. Basically, the flow will enable the user to:

  • Sign in and receive a token (the /signin route)
  • Use the token to access protected endpoints (the /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)),
        )
}
Enter fullscreen mode Exit fullscreen mode

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:

Authentication handlers

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
    })
}
Enter fullscreen mode Exit fullscreen mode

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: User Information Returned By The Server 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: "myemail@gmail.com".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
}

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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> {
Enter fullscreen mode Exit fullscreen mode

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 exist, 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: "myemail@gmail.com".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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 = Cliams { iat, exp, email };

    encode(
        &Header::default(),
        &claim,
        &EncodingKey::from_secret(secret.as_ref()),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
Enter fullscreen mode Exit fullscreen mode

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<Cliams>, StatusCode> {
    let secret = "randomStringTypicallyFromEnv".to_string();
    let result: Result<TokenData<Cliams>, StatusCode> = decode(
        &jwt_token,
        &DecodingKey::from_secret(secret.as_ref()),
        &Validation::default(),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
    result
}
Enter fullscreen mode Exit fullscreen mode

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"))?)?;
Enter fullscreen mode Exit fullscreen mode

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))?;
Enter fullscreen mode Exit fullscreen mode

Middleware for protected routes

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)
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)),)
Enter fullscreen mode Exit fullscreen mode

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 {
   ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Testing with Postman

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
Enter fullscreen mode Exit fullscreen mode

And then signing in with our username and password: Signing Into Postman For Testing Our Rust And Axum App 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: Example Error Message Shown After Making An Api Call To Access A Protected Route Without A Token Add the token. Now we can access the protected API properly: Correctly Accessing The Protected Route With A Token

Conclusion

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!


LogRocket: Full visibility into web frontends for Rust apps

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 Signup

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.

Top comments (0)