In the landscape of 2025’s backend development, Rust has firmly established itself not just as a systems language, but as a premier choice for secure web services. We all know Rust guarantees memory safety—preventing buffer overflows and dangling pointers that plague C++ applications. However, the compiler cannot save you from logic errors.
Implementing security features like Authentication (AuthN), Authorization (AuthZ), and Cross-Site Request Forgery (CSRF) protection requires architectural discipline. A memory-safe application can still leak user data if the JWT implementation is flawed or if role checks are bypassed.
In this deep-dive guide, we are moving beyond “Hello World.” We will build a production-ready security layer using the Axum web framework. We aren’t just pasting code; we are architecting a solution that scales and remains secure against the OWASP Top 10.
What You Will Learn #
- State-of-the-art Password Hashing: Implementing Argon2id (the winner of the Password Hashing Competition).
- Stateless yet Secure Sessions: Using JWTs stored in
HttpOnlycookies (avoiding local storage XSS vulnerabilities). - Role-Based Access Control (RBAC): Leveraging Axum’s type-safe extractors to protect routes.
- CSRF Mitigation: Implementing the Double Submit Cookie pattern.
- Database Integration: Using SQLx for compile-time checked queries.
Prerequisites and Environment Setup #
Before we write a single line of code, ensure your environment is ready. This guide assumes you are comfortable with Rust syntax and basic async concepts.
Requirements:
- Rust: Version 1.80 or higher (Stable).
- Database: PostgreSQL (running via Docker is recommended).
- Tooling:
sqlx-clifor migrations.
1. Project Initialization #
Create a new binary project:
cargo new rust-secure-auth
cd rust-secure-auth2. Dependencies #
We need a robust set of crates. Edit your Cargo.toml. We are using the tokio ecosystem, axum for the web layer, and paseto or jwt for tokens. Here, we stick to the industry-standard jsonwebtoken but implement it rigorously.
[package]
name = "rust-secure-auth"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Framework
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace", "fs"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Database
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
# Security & Cryptography
argon2 = "0.5"
rand = "0.8"
jsonwebtoken = "9"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Utilities
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "1"3. Database Setup #
Spin up a Postgres instance using Docker:
docker run --name rust-auth-db -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:16-alpineCreate a .env file in your project root:
DATABASE_URL=postgres://postgres:password@localhost:5432/postgres
JWT_SECRET=super_secret_key_change_this_in_production
RUST_LOG=debugInitialize the database with SQLx:
cargo install sqlx-cli
sqlx database create
sqlx migrate add create_users_tableIn the generated migration file (inside migrations/), define a schema that supports RBAC:
-- migrations/20260101000000_create_users_table.sql
CREATE TYPE user_role AS ENUM ('user', 'admin', 'moderator');
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Run the migration: sqlx migrate run.
Part 1: The Authentication Architecture #
Before coding, let’s visualize the flow. Security is about layers. We don’t just rely on one check; we rely on a chain of validations.
Request Lifecycle Diagram #
The following diagram illustrates how a request flows through our middleware stack to reach a protected admin handler.
Part 2: Password Hashing with Argon2 #
In 2025, using MD5, SHA1, or even plain SHA-256 for passwords is professional negligence. Even BCrypt is showing its age against FPGA clusters. The standard is Argon2, specifically Argon2id, which resists both GPU cracking and side-channel attacks.
Implementing the Hashing Utility #
Create a file src/security.rs. We need two functions: one to hash, one to verify.
Critical Performance Note: Argon2 is designed to be memory and CPU intensive. Never run this on the main Tokio async thread, or you will block the event loop and kill your server’s throughput. Always wrap it in tokio::task::spawn_blocking.
// src/security.rs
use argon2::{
password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString
},
Argon2,
};
use tokio::task;
#[derive(thiserror::Error, Debug)]
pub enum SecurityError {
#[error("Hashing failed")]
HashError,
#[error("Invalid credentials")]
InvalidCredentials,
}
pub async fn hash_password(password: String) -> Result<String, SecurityError> {
task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
// Hash password to PHC string ($argon2id$v=19$...)
argon2
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| SecurityError::HashError)
})
.await
.map_err(|_| SecurityError::HashError)?
}
pub async fn verify_password(password: String, password_hash: String) -> Result<bool, SecurityError> {
task::spawn_blocking(move || {
let parsed_hash = PasswordHash::new(&password_hash)
.map_err(|_| SecurityError::InvalidCredentials)?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map(|_| true)
.map_err(|_| SecurityError::InvalidCredentials)
})
.await
.map_err(|_| SecurityError::InvalidCredentials)?
}Part 3: JWTs and HttpOnly Cookies #
There is a long-standing debate: Local Storage vs. Cookies.
| Feature | LocalStorage + JWT | HttpOnly Cookie + JWT | Session ID (Server-side) |
|---|---|---|---|
| XSS Vulnerability | High (JS can read token) | Low (JS cannot read token) | Low |
| CSRF Vulnerability | Immune (usually) | High (Browser sends auto) | High |
| Stateless | Yes | Yes | No (Redis/DB required) |
| Complexity | Low | Medium | Medium/High |
Our Choice: HttpOnly Cookies. We prioritize preventing XSS (Cross-Site Scripting), which allows attackers to steal tokens. Since cookies introduce CSRF risks, we will mitigate that separately in Part 5.
Token Structure #
Create src/jwt.rs.
// src/jwt.rs
use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, TokenData};
use chrono::{Utc, Duration};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // Subject (User ID)
pub role: String, // RBAC Role
pub exp: usize, // Expiration
pub iat: usize, // Issued At
}
pub struct TokenService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
}
impl TokenService {
pub fn new(secret: &str) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
}
}
pub fn create_token(&self, user_id: Uuid, role: String) -> Result<String, jsonwebtoken::errors::Error> {
let expiration = Utc::now()
.checked_add_signed(Duration::hours(24))
.expect("valid timestamp")
.timestamp();
let claims = Claims {
sub: user_id.to_string(),
role,
iat: Utc::now().timestamp() as usize,
exp: expiration as usize,
};
encode(&Header::default(), &claims, &self.encoding_key)
}
pub fn validate_token(&self, token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> {
let mut validation = Validation::default();
validation.validate_exp = true;
decode::<Claims>(token, &self.decoding_key, &validation)
}
}Part 4: Axum Extractors for Auth & RBAC #
Axum’s power lies in its Extractors. We can create a custom type AuthUser that, when added as an argument to a handler function, automatically validates the user. If validation fails, the handler is never called.
The AuthUser Extractor
#
This is the glue code. It looks for a cookie, validates the JWT, and enforces roles.
// src/auth_middleware.rs
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json, RequestPartsExt,
};
use axum_extra::extract::cookie::CookieJar;
use serde_json::json;
use std::sync::Arc;
use crate::jwt::TokenService;
// Application State
pub struct AppState {
pub db: sqlx::PgPool,
pub token_service: TokenService,
}
// The core struct we want to inject into handlers
pub struct AuthUser {
pub user_id: uuid::Uuid,
pub role: String,
}
// Error handling specifically for Auth
pub enum AuthError {
MissingToken,
InvalidToken,
InternalError,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "Missing authentication cookie"),
AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid or expired token"),
AuthError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"),
};
let body = Json(json!({ "error": error_message }));
(status, body).into_response()
}
}
#[async_trait]
impl FromRequestParts<Arc<AppState>> for AuthUser {
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
// 1. Extract the Cookie Jar
let jar = parts
.extract::<CookieJar>()
.await
.map_err(|_| AuthError::InternalError)?;
// 2. Get the "auth_token" cookie
let token_cookie = jar.get("auth_token").ok_or(AuthError::MissingToken)?;
let token_str = token_cookie.value();
// 3. Validate JWT
let token_data = state
.token_service
.validate_token(token_str)
.map_err(|_| AuthError::InvalidToken)?;
// 4. Return the AuthUser
Ok(AuthUser {
user_id: uuid::Uuid::parse_str(&token_data.claims.sub).unwrap(),
role: token_data.claims.role,
})
}
}Implementing Role-Based Access Control (RBAC) #
To verify roles, we don’t just want AuthUser, we want specific assurances. We can create a wrapper struct or verify it inside the handler. For cleaner code, let’s create an AdminOnly extractor.
pub struct AdminUser(pub AuthUser);
#[async_trait]
impl FromRequestParts<Arc<AppState>> for AdminUser {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
// Delegate to AuthUser first
let user = AuthUser::from_request_parts(parts, state)
.await
.map_err(|e| e.into_response())?;
if user.role != "admin" {
let error = Json(json!({ "error": "Insufficient permissions" }));
return Err((StatusCode::FORBIDDEN, error).into_response());
}
Ok(AdminUser(user))
}
}Now, any handler asking for AdminUser is automatically secured!
Part 5: CSRF Protection (Double Submit Cookie) #
Since we are using cookies, browsers will automatically send the cookie with requests to our domain, even if the request originated from evil-site.com. This is Cross-Site Request Forgery.
To fix this, we use the Double Submit Cookie pattern (or verify the Origin header, but tokens are safer).
- On login, we set a
csrf_tokencookie (not HttpOnly, readable by JS). - The frontend reads this cookie and sends it back in a custom header
X-CSRF-Token. - The server compares the cookie value with the header value.
Note: For highest security in 2025, combined with SameSite=Strict, this is very robust.
// src/csrf.rs
use axum::{
body::Body,
http::{Request, StatusCode},
middleware::Next,
response::Response,
};
use axum_extra::extract::CookieJar;
pub async fn csrf_middleware(
request: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Skip CSRF check for safe methods (GET, HEAD, OPTIONS)
let method = request.method();
if method == "GET" || method == "HEAD" || method == "OPTIONS" {
return Ok(next.run(request).await);
}
// Extract cookies and headers
let jar = CookieJar::from_headers(request.headers());
let cookie_token = jar.get("csrf_token").map(|c| c.value());
let header_token = request
.headers()
.get("X-CSRF-Token")
.and_then(|h| h.to_str().ok());
match (cookie_token, header_token) {
(Some(cookie), Some(header)) if cookie == header => {
Ok(next.run(request).await)
}
_ => Err(StatusCode::FORBIDDEN),
}
}Part 6: Wiring It All Together (The main.rs)
#
Let’s assemble the login handler and the server.
// src/main.rs
use axum::{
routing::{get, post},
Router, Json, Extension, middleware,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use std::sync::Arc;
use tokio::net::TcpListener;
use sqlx::postgres::PgPoolOptions;
use dotenvy::dotenv;
mod security;
mod jwt;
mod auth_middleware;
mod csrf;
use auth_middleware::{AppState, AuthUser, AdminUser};
use jwt::TokenService;
use security::{hash_password, verify_password};
#[tokio::main]
async fn main() {
dotenv().ok();
// Setup DB
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Failed to connect to DB");
// Setup JWT
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let token_service = TokenService::new(&jwt_secret);
let state = Arc::new(AppState {
db: pool,
token_service,
});
// Routes
let app = Router::new()
.route("/api/admin/dashboard", get(admin_handler)) // Protected (Admin)
.route("/api/me", get(me_handler)) // Protected (User)
.layer(middleware::from_fn(csrf::csrf_middleware)) // Apply CSRF check
.route("/api/login", post(login_handler)) // Public
.route("/api/register", post(register_handler)) // Public
.with_state(state);
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Server running on http://0.0.0.0:3000");
axum::serve(listener, app).await.unwrap();
}
// --- Handlers ---
#[derive(serde::Deserialize)]
struct LoginPayload {
email: String,
password: String,
}
async fn login_handler(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
jar: CookieJar,
Json(payload): Json<LoginPayload>,
) -> impl axum::response::IntoResponse {
// 1. Fetch User from DB
let user_record = sqlx::query!(
"SELECT id, password_hash, role::text as role FROM users WHERE email = $1",
payload.email
)
.fetch_optional(&state.db)
.await
.unwrap();
if let Some(user) = user_record {
// 2. Verify Password
if verify_password(payload.password, user.password_hash).await.unwrap_or(false) {
// 3. Generate JWT
let token = state.token_service.create_token(user.id, user.role).unwrap();
//