Authentication
FORGE generates a complete JWT-based authentication system with support for multiple login methods, token refresh, and secure password handling. The system uses short-lived access tokens paired with longer-lived refresh tokens, following industry best practices for stateless API authentication.
Token Strategy
The authentication system uses a dual-token approach:
| Token | Lifetime | Purpose | Storage |
|---|---|---|---|
| Access Token | 15 min | Authorize API requests | Authorization header or HttpOnly cookie |
| Refresh Token | 7 days | Obtain new access tokens | HttpOnly cookie |
┌──────────┐ Login ┌──────────┐
│ Client │ ──────────────▶│ Server │
│ │◀────────────── │ │
│ │ Access Token │ │
│ │ Refresh Token │ │
│ │ │ │
│ │ API Request │ │
│ │ ──────────────▶│ │
│ │ (Access Token)│ │
│ │ │ │
│ │ Token Expired │ │
│ │ ──────────────▶│ │
│ │ (Refresh Token)│ │
│ │◀────────────── │ │
│ │ New Tokens │ │
└──────────┘ └──────────┘TIP
Access tokens are intentionally short-lived. When an access token expires, the client should call the refresh endpoint to obtain a new pair of tokens without requiring the user to log in again.
Login Methods
FORGE supports three login methods out of the box:
Email + Password
The standard login flow using email and password credentials.
// POST /api/auth/login
#[derive(Deserialize, Validate)]
pub struct LoginRequest {
#[validate(email)]
pub email: String,
#[validate(length(min = 8))]
pub password: String,
}Mobile + Password
For applications that use phone numbers as the primary identifier.
// POST /api/auth/login
#[derive(Deserialize, Validate)]
pub struct MobileLoginRequest {
#[validate(length(min = 8, max = 15))]
pub mobile: String,
#[validate(length(min = 8))]
pub password: String,
}Mobile + OTP
Passwordless authentication using one-time passwords sent via SMS.
// Step 1: Request OTP
// POST /api/auth/send-otp
#[derive(Deserialize, Validate)]
pub struct SendOtpRequest {
#[validate(length(min = 8, max = 15))]
pub mobile: String,
}
// Step 2: Verify OTP and authenticate
// POST /api/auth/verify-otp
#[derive(Deserialize, Validate)]
pub struct VerifyOtpRequest {
#[validate(length(min = 8, max = 15))]
pub mobile: String,
#[validate(length(equal = 6))]
pub otp: String,
}Admin Two-Factor Authentication (2FA)
When both email_password and mobile_otp are enabled in your project configuration, FORGE automatically implements Two-Factor Authentication for admin panel access. This provides an additional security layer for administrative accounts.
2FA Flow
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Email/Password │ ──▶ │ Send OTP to │ ──▶ │ Verify OTP │ ──▶ Admin Panel
│ Login │ │ Admin's Mobile │ │ (6 digits) │
└─────────────────┘ └─────────────────┘ └─────────────────┘How It Works
- User submits email/password → Server validates credentials
- If user has mobile number → Server generates OTP, sends to mobile, returns
requires_otp: truewith a temporary token - User enters OTP → Server verifies OTP and temporary token, returns access tokens
- If user has no mobile → 2FA is skipped, normal login proceeds
Login Response Types
When 2FA is enabled, the login endpoint returns a unified response:
Standard login (no 2FA required):
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": "...", "name": "...", "email": "..." }
}
}2FA required:
{
"success": true,
"data": {
"requires_otp": true,
"temp_token": "dXNlcl9pZDoxNzA2MTIzNDU2...",
"mobile_masked": "+971*****567"
}
}Verify 2FA Endpoint
Complete the 2FA flow by verifying the OTP:
POST /api/auth/otp/verify-2fa
Content-Type: application/json
{
"temp_token": "dXNlcl9pZDoxNzA2MTIzNDU2...",
"code": "123456"
}Response (200):
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": "...", "name": "...", "email": "..." }
}
}Security Features
| Feature | Description |
|---|---|
| Temporary Token | HMAC-SHA256 signed, 5-minute expiry |
| Max Attempts | 3 OTP attempts before requiring re-login |
| Mobile Masking | Display +971*****567 for privacy |
| OTP Expiry | 5 minutes (configurable via OTP_EXPIRY) |
Development Mode
For easier testing, when APP_ENV=development, the OTP code is always 123456:
fn generate_otp_code(length: usize) -> String {
if std::env::var("APP_ENV").unwrap_or_default() == "development" {
return "123456".to_string();
}
// Production: generate random OTP
// ...
}Seed Users
When mobile_otp is enabled, seed users include mobile numbers:
| User | Mobile | Password | |
|---|---|---|---|
| Admin | admin@ | +971501234567 | password |
| User | user@ | +971509876543 | password |
Testing 2FA
- Run
forge new myappwith bothemail_passwordandmobile_otpenabled - Start the API with
APP_ENV=development - Login at
/admin/loginwithadmin@{domain}/password - Enter static OTP
123456on the verification screen
API Endpoints
Login
Authenticate a user and receive tokens.
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword"
}Response (200):
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"email": "user@example.com"
}
}
}Register
Create a new user account.
POST /api/auth/register
Content-Type: application/json
{
"name": "Jane Doe",
"email": "jane@example.com",
"password": "securepassword",
"password_confirmation": "securepassword"
}Refresh Token
Exchange a valid refresh token for a new token pair.
POST /api/auth/refresh
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}WARNING
Refresh tokens are single-use. Each call to the refresh endpoint invalidates the previous refresh token and issues a new pair. If a refresh token is used twice, both tokens are revoked as a security precaution (refresh token rotation).
Logout
Invalidate the current session tokens.
POST /api/auth/logout
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...Forgot Password
Initiate the password reset flow by sending a reset link to the user's email.
POST /api/auth/forgot-password
Content-Type: application/json
{
"email": "user@example.com"
}Reset Password
Complete the password reset using the token from the email.
POST /api/auth/reset-password
Content-Type: application/json
{
"token": "reset-token-from-email",
"password": "newsecurepassword",
"password_confirmation": "newsecurepassword"
}Send OTP
Request a one-time password for mobile authentication.
POST /api/auth/send-otp
Content-Type: application/json
{
"mobile": "+966500000000"
}Verify OTP
Verify the OTP and authenticate the user.
POST /api/auth/verify-otp
Content-Type: application/json
{
"mobile": "+966500000000",
"otp": "123456"
}Password Hashing
All passwords are hashed using Argon2id, the winner of the Password Hashing Competition and the recommended algorithm for new applications.
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
/// Hash a plaintext password using Argon2id
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|_| AppError::InternalError("Failed to hash password".into()))?;
Ok(hash.to_string())
}
/// Verify a password against a stored hash
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|_| AppError::InternalError("Invalid password hash".into()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}TIP
FORGE uses Argon2id with default parameters (19 MiB memory, 2 iterations, 1 degree of parallelism). These settings provide strong security while keeping login response times under 500ms on modern hardware.
JWT Token Structure
Access tokens carry the user's identity and are signed with HMAC-SHA256:
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // User ID (UUID)
pub email: String, // User email
pub exp: usize, // Expiration timestamp
pub iat: usize, // Issued at timestamp
pub token_type: String, // "access" or "refresh"
}Token generation:
use jsonwebtoken::{encode, Header, EncodingKey};
pub fn generate_access_token(user: &User, secret: &str) -> Result<String, AppError> {
let now = chrono::Utc::now();
let claims = Claims {
sub: user.id.to_string(),
email: user.email.clone(),
exp: (now + chrono::Duration::minutes(15)).timestamp() as usize,
iat: now.timestamp() as usize,
token_type: "access".to_string(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|_| AppError::InternalError("Token generation failed".into()))
}Token Delivery
Tokens can be delivered in two ways depending on the client type:
HTTP-Only Cookie (recommended for web apps):
let cookie = format!(
"access_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900",
access_token
);Authorization Header (for mobile/SPA clients):
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...WARNING
Always prefer HttpOnly cookies for browser-based applications. They are immune to XSS attacks because JavaScript cannot access the token value. Use the Authorization header only for non-browser clients (mobile apps, CLI tools).
Environment Variables
Configure authentication behavior through environment variables:
# JWT signing secret (required, use a strong random value)
JWT_SECRET=your-256-bit-secret-key-here
# Token lifetimes
ACCESS_TOKEN_EXPIRY=900 # 15 minutes in seconds
REFRESH_TOKEN_EXPIRY=604800 # 7 days in seconds
# Password reset
RESET_TOKEN_EXPIRY=3600 # 1 hour in seconds
# OTP settings
OTP_LENGTH=6
OTP_EXPIRY=300 # 5 minutes in secondsSee Also
- Authorization — Role-based access control after authentication
- Middleware — How the auth middleware extracts and validates tokens
- Error Handling — Authentication error responses