Build a Secure API in Rust with JWT Authentication Using Axum

In today's world, securing APIs is a top priority for web developers, and one of the most effective methods of securing endpoints is JWT-based authentication. JSON Web Tokens (JWTs) are a compact and self-contained way to securely transmit information between parties, making them ideal for authenticating users in web applications.

In this tutorial, we'll walk you through creating a simple Axum API in Rust that leverages JWTs to authenticate users. By the end of the article, you'll have a working example where:

  • A route generates a JWT.
  • A protected route is secured using JWT authentication.

Let’s dive in!


Step 1: Setting Up Axum and Dependencies

Before starting, create a new Rust project:

cargo new jwt_axum_auth
cd jwt_axum_auth

Now, open the Cargo.toml file and add these dependencies:

[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
jsonwebtoken = "7.2"

These dependencies include Axum (web framework), Tokio (async runtime), Serde (serialization), and jsonwebtoken (for handling JWTs). Run cargo build to make sure everything is installed correctly.


Step 2: Building a Basic Axum Server

Let’s start by setting up a minimal Axum server with a simple route to ensure everything works.

In your src/main.rs, add the following:

use axum::{Router, routing::get};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    // Create a basic router with a hello world endpoint
    let app = Router::new().route("/", get(hello));

    // Define the address the server will run on
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

    // Start the server
    println!("Server running at http://{addr}");
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn hello() -> &'static str {
    "Hello, World!"
}

This code sets up a basic Axum app with one route (/) that simply returns "Hello, World!". To run the app, execute:

cargo run

You should be able to visit http://localhost:3000 and see "Hello, World!" displayed.


Step 3: Implementing JWT Authentication

Now we’ll implement JWT creation and validation. JSON Web Tokens are used to securely transmit information between a client and server, allowing us to authenticate requests.

In the main.rs file, add the following to handle the JWT logic:

use jsonwebtoken::{encode, decode, Header, Algorithm, EncodingKey, DecodingKey, Validation};
use serde::{Serialize, Deserialize};
use std::time::{SystemTime, UNIX_EPOCH};

// Define a struct to represent the claims in the JWT
#[derive(Serialize, Deserialize)]
struct Claims {
    sub: String, // Subject (user ID or username)
    exp: usize,  // Expiration time
}

// Secret key for signing and verifying JWTs
const SECRET_KEY: &[u8] = b"secret";

// Function to generate a JWT
fn generate_jwt(sub: String) -> String {
    let exp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600; // 1 hour expiration
    let claims = Claims { sub, exp: exp as usize };
    
    let header = Header::new(Algorithm::HS256);
    let encoding_key = EncodingKey::from_secret(SECRET_KEY);
    
    encode(&header, &claims, &encoding_key).unwrap()
}

// Function to validate a JWT
fn validate_jwt(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
    let decoding_key = DecodingKey::from_secret(SECRET_KEY);
    let validation = Validation::new(Algorithm::HS256);
    
    decode::<Claims>(token, &decoding_key, &validation).map(|data| data.claims)
}

Explanation of JWT Handling:

  1. Claims: A struct that holds the JWT payload. Here, it contains the subject (sub), which could be the user ID or username, and the expiration time (exp).
  2. generate_jwt: A function that generates a JWT with a subject (like a username) and an expiration time (1 hour from the current time).
  3. validate_jwt: A function to decode and validate the JWT. If the token is valid and not expired, it returns the claims; otherwise, it returns an error.

Step 4: Securing Routes with JWT Authentication

Next, we’ll secure routes by adding a middleware that checks for a valid JWT before allowing access to certain endpoints.

Create the middleware function in main.rs:

use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::Response;

async fn jwt_auth<B>(req: Request<B>, next: Next<B>) -> Result<Response, StatusCode> {
    let headers = req.headers();
    if let Some(auth_header) = headers.get("Authorization") {
        if let Ok(auth_str) = auth_header.to_str() {
            if let Some(token) = auth_str.strip_prefix("Bearer ") {
                match validate_jwt(token) {
                    Ok(_) => return Ok(next.run(req).await), // JWT is valid, proceed to next handler
                    Err(_) => return Err(StatusCode::UNAUTHORIZED), // Invalid JWT
                }
            }
        }
    }
    Err(StatusCode::UNAUTHORIZED) // No Authorization header or invalid token
}
  • jwt_auth: This function checks the Authorization header for a Bearer token. If valid, the request is passed to the next handler; otherwise, it returns a 401 Unauthorized.

Now, we’ll modify the main app to apply this middleware to a protected route:

use axum::middleware::from_fn;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/protected", get(protected)) // Protected route
        .layer(from_fn(jwt_auth)) // Apply JWT authentication middleware
        .route("/", get(hello))
        .route("/generate", get(generate_jwt_route)); // Route to generate JWTs
        
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("Server running at http://{addr}");
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn protected() -> &'static str {
    "This is a protected route!"
}

// Route to generate JWT
async fn generate_jwt_route() -> String {
    let token = generate_jwt("user123".to_string()); // Example user ID
    format!("Generated JWT: {}", token)
}

What’s New:

  • /protected: A route that is protected by JWT authentication. You must send a valid JWT to access it.
  • /generate: A route that generates a JWT for the user user123 and returns it in the response.

To generate a JWT, visit http://localhost:3000/generate. You’ll receive a JWT token, which can be used in the Authorization header as a Bearer token to access the /protected route.


Concepts and Explanations

  1. JWT Basics: JWTs are compact, URL-safe tokens that contain three parts: the header, the payload, and the signature. They allow information exchange between a client and server in a secure manner.

  2. Axum Middleware: Middleware in Axum runs before or after route handlers. In our case, it’s used to validate JWTs before accessing protected routes.

  3. Expiration Handling: We set an expiration (exp) for the token to prevent unauthorized use beyond a certain period.


Challenges or Questions

  • Modify the /generate route to accept a dynamic user ID via query parameters (e.g., /generate?user_id=someuser).
  • Implement a refresh token system where a user can refresh their JWT after it expires.

Recap and Conclusion

In this tutorial, we learned how to secure routes in an Axum API using JWTs. Here’s a recap of what we covered:

  1. Setting up Axum: We created a simple Axum server.
  2. JWT creation and validation: We implemented functions for generating and validating JWTs.
  3. Securing routes: We added middleware to protect routes requiring authentication.
  4. Generating JWTs: We created a route that allows us to generate a JWT programmatically.

This setup provides a foundation for adding secure authentication to your Axum APIs.

As a next step, you might want to explore adding refresh tokens, handling different user roles, or integrating with a real database to store user information.

Happy coding!