DEV Community

Cover image for Part 1: Implementing JWT Authentication in Rust with Axum.
Simon Bittok
Simon Bittok

Posted on

Part 1: Implementing JWT Authentication in Rust with Axum.

In this article, we'll build a backend service in Rust with JWT (JSON Web Token) authentication. We'll use the Axum framework for routing and SQLx for persistent storage with PostgreSQL.

I'll demonstrate how to implement RSA (Rivest-Shamir-Adleman) encryption to create both access and refresh tokens for route protection. RSA uses asymmetric cryptography with a public-private key pair to sign and verify tokens, providing robust security for your authentication system.

Getting Started

First, create a new project using Cargo:

cargo new axum-auth
Enter fullscreen mode Exit fullscreen mode

I prefer organizing my project by placing main.rs inside a src/bin/ directory and creating a lib.rs file in the src/ directory to hold module declarations. This separation keeps the codebase organized as it grows.

Next install the required dependencies.

cargo add axum -F macros
cargo add tokio -F full
cargo add color-eyre
cargo add config -F yaml
cargo add thiserror
cargo add serde -F derive
cargo add serde-json
cargo add tracing -F log
cargo add tracing-subscriber -F "env-filter, serde, tracing, json"
cargo add tracing-error
Enter fullscreen mode Exit fullscreen mode

Dependency Overview

  • axum: Web framework with the macros feature for routing utilities
  • tokio: Async runtime with the full feature set
  • color-eyre: Enhanced error reporting
  • thiserror: Ergonomic error type derivation
  • config: Read configuration files & deserialize to Rust data structures.
  • serde: A serialization/deserialization library.
  • tracing, tracing-subscriber, tracing-error: Will provide logging utilities.

Configuration

The 12 factor app methodology recommends we seperate configuration from code, so that we can easily modify without recompilation, especially taking into account Rust compile time ;).

We will read configs from environment-specific configuration files. This approach allows us to maintain different configurations for development, production, and testing environments.

Create a config folder parallel to the src/ directory then, create a development.yaml file inside it and add the following content.

server:
  protocol: http
  host: 127.0.0.1
  port: 7150

Enter fullscreen mode Exit fullscreen mode

Next create a config/mod.rs inside the src/ dir and add the following.

use serde::Deserialize;

use crate::Result;

#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
    protocol: String,
    host: String,
    port: u16,
}

impl ServerConfig {
    pub fn address(&self) -> String {
        format!("{}:{}", &self.host, &self.port)
    }

    pub fn url(&self) -> String {
        format!("{}://{}", &self.protocol, self.address())
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct Config {
    server: ServerConfig,
}

impl Config {
    pub fn load() -> Result<Self> {
        let env = Environment::current();
        Self::from_env(&env)
    }

    /// Load configuration for a specific environment
    ///
    /// If environment variables are set with prefix APP_, it will also read them
    /// e.g. APP_CONFIG__PORT=5000
    pub fn from_env(env: &Environment) -> Result<Self> {
        let base_dir = std::env::current_dir()?;
        let config_dir = base_dir.join("config");

        let file_name = format!("{}.yaml", env);

        let settings = config::Config::builder()
            .add_source(config::File::from(config_dir.join(file_name)))
            .add_source(
                config::Environment::with_prefix("APP")
                    .separator("__")
                    .prefix_separator("_"),
            )
            .build()?;

        settings.try_deserialize::<Self>().map_err(Into::into)
    }

    pub fn server(&self) -> &ServerConfig {
        &self.server
    }
}

#[derive(Debug, Deserialize, Clone, Default)]
pub enum Environment {
    #[default]
    Development,
    Production,
    Testing,
    Other(String),
}

impl Environment {
    /// Get the current environment from environment variables
    ///
    /// Checks `APP_ENVIRONMENT` then `APP_ENV`, defaults to Development
    pub fn current() -> Self {
        std::env::var("APP_ENVIRONMENT")
            .or_else(|_| std::env::var("APP_ENV"))
            .map(|s| Self::from(s.as_str()))
            .unwrap_or_default()
    }
}

impl From<&str> for Environment {
    fn from(s: &str) -> Self {
        match s.to_lowercase().trim() {
            "development" | "dev" => Environment::Development,
            "production" | "prod" => Environment::Production,
            "testing" | "test" => Environment::Testing,
            other => Environment::Other(other.into()),
        }
    }
}

impl std::fmt::Display for Environment {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Self::Development => "development",
                Self::Production => "production",
                Self::Testing => "testing",
                Self::Other(other) => other.as_str(),
            }
        )
    }
}


Enter fullscreen mode Exit fullscreen mode

Understanding the Configuration System.

ServerConfig struct holds the server connection details. It provides two helper methods: address() which formats the host and port for binding (e.g., 127.0.0.1:7150), and url() which creates the full server URL (e.g., http://127.0.0.1:7150).

Config struct is the root configuration container. The load() method automatically detects the current environment and loads the appropriate configuration file. The from_env() method loads configuration from a YAML file based on the environment name and can be overridden by environment variables prefixed with APP_ (using double underscores as separators, e.g., APP__SERVER__PORT=8080). As the app grows we will add more fields to this struct.

Environment enum represents the application's runtime environment. The current() method reads the environment from APP_ENVIRONMENT or APP_ENV environment variables, defaulting to Development if neither is set. The From<&str> implementation provides flexible parsing, accepting variations like "dev"/"development" or "prod"/"production", and includes an Other variant for custom environment names.

Setting up Error Handling

We need a unified error handling for our project. Create an error/mod.rs file and add the following code.

use std::fmt::{self, Display};

#[derive(Debug)]
pub struct Report(pub color_eyre::Report);

impl<E> From<E> for Report
where
    E: Into<color_eyre::Report>,
{
    fn from(err: E) -> Self {
        Self(err.into())
    }
}

impl Display for Report {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

Enter fullscreen mode Exit fullscreen mode
pub type Result<T, E = Report> = std::result::Result<T, E>;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error(transparent)]
    Axum(#[from] axum::Error),
    #[error(transparent)]
    Config(#[from] config::ConfigError),
    #[error(transparent)]
    IOError(#[from] std::io::Error),
}
Enter fullscreen mode Exit fullscreen mode

The thiserror crate provides the Error trait, which automatically implements both the Display trait and From<E> conversions for our error types. This gives us easy and efficient (ergonomic) error handling with automatic conversion from underlying error types.

The color_eyre::Report type serves as our catch-all error wrapper. It can accept any error that implements std::error::Error, providing enhanced error reporting with backtraces and color-formatted output. By wrapping it in our own Report struct, we can handle both our explicitly defined errors (in the Error enum) and any other errors from third-party libraries without compilation issues.

The App struct

Create a new file app.rs inside src/ and add the following.

use std::io::IsTerminal;

use axum::{Router, routing::get};
use color_eyre::config::{HookBuilder, Theme};
use tokio::net::TcpListener;

use crate::{Result, config::Config};

pub struct App;

impl App {
    pub async fn run() -> Result<()> {
        HookBuilder::new().theme(if std::io::stderr().is_terminal() {
            Theme::dark()
        } else {
            Theme::new()
        });

        let config = Config::load()?;

        let router = Router::new().route("/hello", get(|| async { "Hello from axum!" }));

        let listener = TcpListener::bind(config.server().address()).await?;

        axum::serve(listener, router).await.map_err(Into::into)
    }
}

Enter fullscreen mode Exit fullscreen mode

Then call the App::run function inside the main.rs file.

use auth::{App, Result}; //ensure to export App & Result from the lib.rs file

#[tokio::main]
async fn main() -> Result<()> {
    App::run().await
}


Enter fullscreen mode Exit fullscreen mode

With that now we no longer need to touch our main.rs file.

Run the application and then test the endpoint using cURL or the browser at http:127.0.0.1:7150/hello. You should get response Hello from axum.

The Hookbuilder will come in handy later when we implement logging. At the moment do not worry much about it.

In the next chapter we will implement logging.

Top comments (0)