Skip to main content

Overview

Ironclad provides a complete authentication system built on industry-standard security practices:
  • JWT tokens for stateless authentication
  • Bcrypt password hashing with configurable cost factors
  • Environment-specific security validation
  • Clean separation between authentication logic and HTTP layer

Authentication Flow

The authentication flow follows a layered architecture:
Client Request → Controller → Service → Repository → Database
                     ↓            ↓
                 Validation   Password Hash/Verify
                              JWT Generation

User Registration

Registration Endpoint

The registration endpoint uses automatic validation via ValidatedJson extractor:
use actix_web::{web, HttpResponse};
use crate::application::dtos::RegisterUserRequest;
use crate::application::services::AuthService;
use crate::shared::ValidatedJson;

pub async fn register(
    service: web::Data<Arc<AuthService>>,
    req: ValidatedJson<RegisterUserRequest>,
) -> ApiResult<HttpResponse> {
    let response = service.register(req.0).await?;
    Ok(HttpResponse::Created().json(response))
}
See auth_controller.rs:14-20 for the full implementation.

Registration Request DTO

The RegisterUserRequest uses the validator crate for declarative validation:
use serde::{Deserialize, Serialize};
use validator::Validate;

#[derive(Debug, Deserialize, Serialize, Validate)]
pub struct RegisterUserRequest {
    #[validate(email(message = "Invalid email format"))]
    pub email: String,
    
    #[validate(length(min = 3, max = 50, message = "Username must be between 3 and 50 characters"))]
    pub username: String,
    
    #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
    pub password: String,
}
See auth_dto.rs:6-16 for the complete definition.

Registration Service Logic

The AuthService::register method implements the complete registration flow:
pub async fn register(&self, request: RegisterUserRequest) -> Result<AuthResponse, ApiError> {
    // 1. Validate strong password requirements
    if validate_strong_password(&request.password).is_err() {
        return Err(ApiError::ValidationError(
            "Password does not meet security requirements".to_string()
        ));
    }

    // 2. Check if user already exists (async validation)
    if self.user_repository.exists_by_email(&request.email).await? {
        return Err(ApiError::Conflict("User already exists".to_string()));
    }
    
    // 3. Create validated Value Objects
    let email_vo = EmailAddress::new(request.email)?;
    let username_vo = Username::new(request.username)?;
    
    // 4. Hash password with bcrypt
    let password_hash = hash_password(&request.password, &self.config)?;
    
    // 5. Create domain entity
    let user = User::new(email_vo, username_vo, password_hash)?;
    
    // 6. Persist to database
    let created_user = self.user_repository.create(&user).await?;
    
    // 7. Generate JWT token
    let token = create_token(
        &created_user.id,
        created_user.email.as_str(),
        &created_user.role.to_string(),
        &self.config,
    )?;
    
    Ok(AuthResponse {
        user: created_user.to_response(),
        token,
    })
}
See auth_service.rs:26-61 for the full implementation.

User Login

Login Endpoint

pub async fn login(
    service: web::Data<Arc<AuthService>>,
    req: ValidatedJson<LoginRequest>,
) -> ApiResult<HttpResponse> {
    let response = service.login(req.0).await?;
    Ok(HttpResponse::Ok().json(response))
}
See auth_controller.rs:23-29.

Login Request DTO

#[derive(Debug, Deserialize, Serialize, Validate)]
pub struct LoginRequest {
    #[validate(email(message = "Invalid email format"))]
    pub email: String,
    
    #[validate(length(min = 1, message = "Password is required"))]
    pub password: String,
}
See auth_dto.rs:19-26.

Login Service Logic

The login service verifies credentials and returns a JWT token:
pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, ApiError> {
    // 1. Find user by email
    let user = self
        .user_repository
        .get_by_email(&request.email)
        .await?
        .ok_or(ApiError::Unauthorized)?;

    // 2. Check if account is active
    if !user.is_active() {
        return Err(ApiError::Forbidden("Account is disabled".to_string()));
    }

    // 3. Verify password against bcrypt hash
    if !verify_password(&request.password, &user.password_hash)? {
        return Err(ApiError::Unauthorized);
    }

    // 4. Generate JWT token
    let token = create_token(
        &user.id,
        user.email.as_str(),
        &user.role.to_string(),
        &self.config,
    )?;

    Ok(AuthResponse {
        user: user.to_response(),
        token,
    })
}
See auth_service.rs:64-90.

Password Hashing with Bcrypt

Ironclad uses bcrypt for secure password hashing with configurable cost factors.

Hashing Passwords

use bcrypt::hash;
use crate::config::AppConfig;
use crate::errors::ApiError;

pub fn hash_password(password: &str, config: &AppConfig) -> Result<String, ApiError> {
    let cost = config.bcrypt.cost;
    
    hash(password, cost)
        .map_err(|e| ApiError::InternalServerError(format!("Error hashing password: {}", e)))
}
See auth.rs:7-12.

Verifying Passwords

use bcrypt::verify;

pub fn verify_password(password: &str, hash: &str) -> Result<bool, ApiError> {
    verify(password, hash)
        .map_err(|e| ApiError::InternalServerError(format!("Error verifying password: {}", e)))
}
See auth.rs:15-18.

Bcrypt Configuration

Bcrypt cost factors are validated based on environment:
Minimum cost: 10 (recommended: 12)The application will exit if production cost is below 10:
if config.bcrypt.cost < 10 {
    tracing::error!(
        "🔴 BCRYPT_COST={} is TOO LOW for production (minimum: 10, recommended: 12)", 
        config.bcrypt.cost
    );
    std::process::exit(1);
}
See validators.rs:33-41.

Environment Configuration

Configure bcrypt in your .env file:
# Development
BCRYPT_COST=6

# Production
BCRYPT_COST=12

Authentication Response

Both registration and login return the same response structure:
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthResponse {
    pub user: UserResponse,
    pub token: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct UserResponse {
    pub id: String,
    pub email: String,
    pub username: String,
    pub role: String,
    pub is_active: bool,
    pub created_at: String,
    pub updated_at: String,
}
See auth_dto.rs:62-66 and auth_dto.rs:50-59.

Example Response

{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "username": "johndoe",
    "role": "user",
    "is_active": true,
    "created_at": "2024-03-04T12:00:00Z",
    "updated_at": "2024-03-04T12:00:00Z"
  },
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Security Best Practices

Multi-Layer Validation

Ironclad implements validation at multiple layers:
  1. DTO validation (structure, format) - see Validation
  2. Strong password rules (complexity requirements)
  3. Business logic validation (unique email, active account)
  4. Domain validation (value objects)

Password Requirements

Passwords must meet the following criteria:
pub fn validate_strong_password(password: &str) -> Result<(), ValidationError> {
    let has_uppercase = password.chars().any(|c| c.is_uppercase());
    let has_lowercase = password.chars().any(|c| c.is_lowercase());
    let has_digit = password.chars().any(|c| c.is_numeric());
    let has_special = password.chars().any(|c| !c.is_alphanumeric());

    if has_uppercase && has_lowercase && has_digit && has_special {
        Ok(())
    } else {
        Err(ValidationError::new("weak_password"))
    }
}
See validator/mod.rs:4-15. Passwords must contain:
  • At least one uppercase letter
  • At least one lowercase letter
  • At least one digit
  • At least one special character
  • Minimum 8 characters (enforced by DTO validation)

Account Status Checking

The login flow checks if accounts are active:
if !user.is_active() {
    return Err(ApiError::Forbidden("Account is disabled".to_string()));
}
This allows administrators to disable accounts without deletion.

Example: Complete Registration Flow

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "username": "johndoe",
    "password": "SecureP@ss123"
  }'
Response:
{
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "email": "[email protected]",
    "username": "johndoe",
    "role": "user",
    "is_active": true,
    "created_at": "2024-03-04T12:00:00Z",
    "updated_at": "2024-03-04T12:00:00Z"
  },
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciIsImV4cCI6MTcwOTU2MzIwMCwiaWF0IjoxNzA5NTU2MDAwfQ.signature"
}

Example: Login Flow

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "SecureP@ss123"
  }'
Use the returned JWT token for authenticated requests:
curl http://localhost:8080/api/users/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Authorization

Learn about role-based access control and authorization extractors

JWT Tokens

Deep dive into JWT token generation, validation, and claims

Validation

Input validation with the validator crate and custom validators

Build docs developers (and LLMs) love