Authentication

Ferro provides session-based authentication with an Auth facade, Authenticatable trait, UserProvider interface, password hashing (bcrypt), and route protection middleware.

Quick Start

Scaffold a complete auth system with one command:

ferro make:auth

This generates:

  • User migration with email and password fields
  • Authenticatable implementation on your User model
  • DatabaseUserProvider for user retrieval
  • AuthController with register, login, and logout handlers
  • Routes with auth/guest middleware

Configuration

Session

Authentication state is stored in server-side sessions. Configure via environment variables:

VariableDefaultDescription
SESSION_LIFETIME120Idle timeout in minutes (expires after inactivity)
SESSION_ABSOLUTE_LIFETIME43200Absolute timeout in minutes (30 days; expires regardless of activity)
SESSION_COOKIEferro_sessionCookie name
SESSION_SECUREtrueHTTPS-only cookies
SESSION_PATH/Cookie path
SESSION_SAME_SITELaxSameSite attribute (Strict, Lax, None)

Load configuration from environment:

#![allow(unused)]
fn main() {
use ferro::session::SessionConfig;

let config = SessionConfig::from_env();
}

Session Expiry

Ferro enforces two independent session timeouts per OWASP recommendations:

  • Idle timeout (SESSION_LIFETIME): Sessions expire after a period of inactivity. Default: 2 hours.
  • Absolute timeout (SESSION_ABSOLUTE_LIFETIME): Sessions expire after a fixed duration regardless of activity. Default: 30 days. Prevents stolen sessions from being kept alive indefinitely.

Both timeouts are enforced server-side. The session cookie's Max-Age is set to the longer of the two values.

For high-security applications, OWASP recommends shorter values:

Security LevelIdle TimeoutAbsolute Timeout
Standard web app30-60 min4-8 hours
Financial/medical5-15 min1-2 hours
Framework default120 min30 days

Session Invalidation

Destroy sessions for security-sensitive operations like password changes:

#![allow(unused)]
fn main() {
// Logout all other devices (keeps current session)
if let Some(result) = Auth::logout_other_devices().await {
    let destroyed_count = result?;
}

// Logout and invalidate current session
Auth::logout_and_invalidate();
}

Use logout_other_devices() after password changes to invalidate potentially compromised sessions on other devices.

For admin/security flows that need to invalidate sessions for any user:

#![allow(unused)]
fn main() {
use ferro::session::{invalidate_all_for_user, DatabaseSessionDriver};

let store = DatabaseSessionDriver::new(idle_lifetime, absolute_lifetime);
let destroyed = invalidate_all_for_user(&store, user_id, None).await?;
}

Password Hashing

Ferro uses bcrypt with a default cost factor of 12 (same as Laravel). The cost factor is not configurable via environment variables -- change it by calling hashing::hash_with_cost() directly.

The Authenticatable Trait

Implement Authenticatable on your User model to enable Auth::user() and Auth::user_as::<T>().

#![allow(unused)]
fn main() {
use ferro::auth::Authenticatable;
use std::any::Any;

impl Authenticatable for User {
    fn auth_identifier(&self) -> i64 {
        self.id as i64
    }

    fn as_any(&self) -> &dyn Any {
        self
    }
}
}

Methods

MethodDefaultDescription
auth_identifier(&self) -> i64RequiredReturns the user's unique ID (primary key)
auth_identifier_name(&self) -> &'static str"id"Column name for the identifier
as_any(&self) -> &dyn AnyRequiredEnables downcasting via Auth::user_as::<T>()

User Provider

The UserProvider trait retrieves users from your data store. Register it in bootstrap.rs to enable Auth::user().

#![allow(unused)]
fn main() {
use ferro::auth::{UserProvider, Authenticatable};
use ferro::FrameworkError;
use async_trait::async_trait;
use std::sync::Arc;

pub struct DatabaseUserProvider;

#[async_trait]
impl UserProvider for DatabaseUserProvider {
    async fn retrieve_by_id(
        &self,
        id: i64,
    ) -> Result<Option<Arc<dyn Authenticatable>>, FrameworkError> {
        let user = User::query()
            .filter(Column::Id.eq(id as i32))
            .first()
            .await?;
        Ok(user.map(|u| Arc::new(u) as Arc<dyn Authenticatable>))
    }
}
}

Methods

MethodRequiredDescription
retrieve_by_id(id)YesLoad user by primary key
retrieve_by_credentials(credentials)NoLoad user by arbitrary credentials (e.g., email lookup)
validate_credentials(user, credentials)NoCheck credentials against a user (e.g., password verification)

Registration

Register the provider in bootstrap.rs:

#![allow(unused)]
fn main() {
bind!(dyn UserProvider, DatabaseUserProvider);
}

Auth Facade

The Auth struct provides static methods for all authentication operations.

Checking Authentication State

#![allow(unused)]
fn main() {
use ferro::Auth;

// Check if a user is authenticated
if Auth::check() {
    // ...
}

// Check if the user is a guest
if Auth::guest() {
    // ...
}

// Get the authenticated user's ID
if let Some(user_id) = Auth::id() {
    println!("User ID: {}", user_id);
}

// Get the ID as a different type (e.g., i32 for SeaORM)
let user_id: i32 = Auth::id_as().expect("must be authenticated");
}

Logging In

#![allow(unused)]
fn main() {
// Log in by user ID
// Regenerates session ID (prevents session fixation) and CSRF token
Auth::login(user_id);

// Log in with "remember me"
Auth::login_remember(user_id, &remember_token);
}

Logging Out

#![allow(unused)]
fn main() {
// Clear authentication state, regenerate CSRF token
Auth::logout();

// Destroy entire session (logout everywhere)
Auth::logout_and_invalidate();
}

Credential Validation

#![allow(unused)]
fn main() {
// Attempt authentication: validates credentials, logs in on success
let result = Auth::attempt(|| async {
    let user = find_user_by_email(&email).await?;
    match user {
        Some(u) if ferro::verify(&password, &u.password_hash)? => Ok(Some(u.id as i64)),
        _ => Ok(None),
    }
}).await?;

if result.is_some() {
    // Authenticated and logged in
}

// Validate credentials without logging in (e.g., password confirmation)
let valid = Auth::validate(|| async {
    let user_id = Auth::id().unwrap();
    let user = find_user_by_id(user_id).await?;
    Ok(ferro::verify(&password, &user.password_hash)?)
}).await?;
}

Retrieving the Current User

#![allow(unused)]
fn main() {
// Get as trait object
if let Some(user) = Auth::user().await? {
    println!("User #{}", user.auth_identifier());
}

// Get as concrete type (requires Authenticatable + Clone)
if let Some(user) = Auth::user_as::<User>().await? {
    println!("Welcome, {}!", user.name);
}
}

Method Reference

MethodReturnsDescription
Auth::check()booltrue if authenticated
Auth::guest()booltrue if not authenticated
Auth::id()Option<i64>Authenticated user's ID
Auth::id_as::<T>()Option<T>ID converted to type T
Auth::login(id)()Log in, regenerate session + CSRF
Auth::login_remember(id, token)()Log in with remember-me
Auth::logout()()Clear auth state, regenerate CSRF
Auth::logout_and_invalidate()()Destroy entire session
Auth::logout_other_devices()Option<Result<u64>>Destroy all sessions except current
Auth::attempt(validator)Result<Option<i64>>Validate + auto-login
Auth::validate(validator)Result<bool>Validate without login
Auth::user()Result<Option<Arc<dyn Authenticatable>>>Current user (trait object)
Auth::user_as::<T>()Result<Option<T>>Current user (concrete type)

Handler Extractors

For ergonomic access to the current user in handler signatures, Ferro provides typed extractors that work as handler parameters.

AuthUser<T>

Injects the authenticated user directly into the handler. Returns 401 Unauthenticated if no user is logged in.

#![allow(unused)]
fn main() {
use ferro::{handler, AuthUser, Response, HttpResponse};
use crate::models::users;

#[handler]
pub async fn profile(user: AuthUser<users::Model>) -> Response {
    Ok(HttpResponse::json(serde_json::json!({
        "id": user.id,
        "name": user.name,
        "email": user.email
    })))
}
}

OptionalUser<T>

Same as AuthUser<T>, but returns None for guests instead of a 401 error.

#![allow(unused)]
fn main() {
use ferro::{handler, OptionalUser, Response, HttpResponse};
use crate::models::users;

#[handler]
pub async fn home(user: OptionalUser<users::Model>) -> Response {
    let greeting = match user.as_ref() {
        Some(u) => format!("Welcome back, {}!", u.name),
        None => "Welcome, guest!".to_string(),
    };
    Ok(HttpResponse::json(serde_json::json!({"greeting": greeting})))
}
}

Deref Behavior

Both AuthUser<T> and OptionalUser<T> implement Deref, so you access fields directly on the wrapper:

  • AuthUser<T> derefs to T -- use user.name, not user.0.name
  • OptionalUser<T> derefs to Option<T> -- use user.as_ref(), user.is_some(), etc.

Limitations

AuthUser and OptionalUser count as a FromRequest parameter. Only one FromRequest parameter is allowed per handler signature, so they cannot be combined with FormRequest types in the same handler. If you need both the authenticated user and the request body, use Request and call Auth::user_as::<T>() manually:

#![allow(unused)]
fn main() {
#[handler]
pub async fn update_profile(req: Request) -> Response {
    let user: users::Model = Auth::user_as::<users::Model>().await?
        .ok_or_else(|| HttpResponse::json(serde_json::json!({"error": "Unauthenticated"})).status(401))?;
    let input: UpdateInput = req.json().await?;
    // ... use both user and input
}
}

Password Hashing

Ferro provides bcrypt hashing functions re-exported at the crate root.

#![allow(unused)]
fn main() {
use ferro::{hash, verify, needs_rehash};

// Hash a password (bcrypt, cost 12)
let hashed = ferro::hash("my_password")?;

// Verify a password against a stored hash (constant-time comparison)
let valid = ferro::verify("my_password", &hashed)?;

// Check if a hash was created with a lower cost factor
if ferro::needs_rehash(&stored_hash) {
    let new_hash = ferro::hash(&password)?;
    // Update stored hash
}
}

Custom Cost Factor

#![allow(unused)]
fn main() {
use ferro::hashing;

// Hash with a specific cost (higher = slower + more secure)
let hashed = hashing::hash_with_cost("my_password", 14)?;
}

Middleware

AuthMiddleware

Protects routes that require authentication.

#![allow(unused)]
fn main() {
use ferro::{AuthMiddleware, group, get};

// API routes: returns 401 JSON for unauthenticated requests
group!("/api")
    .middleware(AuthMiddleware::new())
    .routes([...]);

// Web routes: redirects to login page
group!("/dashboard")
    .middleware(AuthMiddleware::redirect_to("/login"))
    .routes([...]);
}

Inertia-aware: When an Inertia request hits AuthMiddleware::redirect_to(), it returns a 409 response with X-Inertia-Location header instead of a 302 redirect. This tells the Inertia client to perform a full page visit to the login URL.

GuestMiddleware

Protects routes that should only be accessible to unauthenticated users (login, register pages).

#![allow(unused)]
fn main() {
use ferro::{GuestMiddleware, group, get};

group!("/")
    .middleware(GuestMiddleware::redirect_to("/dashboard"))
    .routes([
        get!("/login", auth::show_login),
        get!("/register", auth::show_register),
    ]);
}

Authenticated users visiting these routes are redirected to the specified path. Also Inertia-aware.

Login and Registration Example

Register Handler

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Auth, json_response};
use ferro::validation::{Validator, rules};

#[handler]
pub async fn register(req: Request) -> Response {
    let data: serde_json::Value = req.json().await?;

    // Validate input
    let errors = Validator::new()
        .rule("name", rules![required(), string(), min(2)])
        .rule("email", rules![required(), email()])
        .rule("password", rules![required(), string(), min(8), confirmed()])
        .validate(&data);

    if errors.fails() {
        return json_response!(422, {
            "message": "Validation failed.",
            "errors": errors
        });
    }

    // Hash password
    let password_hash = ferro::hash(data["password"].as_str().unwrap())?;

    // Insert user
    let user = User::insert(NewUser {
        name: data["name"].as_str().unwrap().to_string(),
        email: data["email"].as_str().unwrap().to_string(),
        password: password_hash,
    }).await?;

    // Log in
    Auth::login(user.id as i64);

    json_response!(201, { "message": "Registered." })
}
}

Login Handler

#![allow(unused)]
fn main() {
#[handler]
pub async fn login(req: Request) -> Response {
    let data: serde_json::Value = req.json().await?;

    let errors = Validator::new()
        .rule("email", rules![required(), email()])
        .rule("password", rules![required(), string()])
        .validate(&data);

    if errors.fails() {
        return json_response!(422, {
            "message": "Validation failed.",
            "errors": errors
        });
    }

    let email = data["email"].as_str().unwrap();
    let password = data["password"].as_str().unwrap();

    // Attempt authentication
    let result = Auth::attempt(|| async {
        let user = User::find_by_email(email).await?;
        match user {
            Some(u) if ferro::verify(password, &u.password)? => Ok(Some(u.id as i64)),
            _ => Ok(None),
        }
    }).await?;

    match result {
        Some(_) => json_response!(200, { "message": "Authenticated." }),
        None => json_response!(401, { "message": "Invalid credentials." }),
    }
}
}

Logout Handler

#![allow(unused)]
fn main() {
#[handler]
pub async fn logout(_req: Request) -> Response {
    Auth::logout();
    json_response!(200, { "message": "Logged out." })
}
}

Route Registration

#![allow(unused)]
fn main() {
use ferro::{group, get, post, AuthMiddleware, GuestMiddleware};

// Guest-only routes (login/register pages)
group!("/")
    .middleware(GuestMiddleware::redirect_to("/dashboard"))
    .routes([
        post!("/register", auth::register),
        post!("/login", auth::login),
    ]);

// Authenticated routes
group!("/")
    .middleware(AuthMiddleware::redirect_to("/login"))
    .routes([
        post!("/logout", auth::logout),
        get!("/dashboard", dashboard::index),
    ]);
}

Security

Ferro's auth system applies these protections:

ProtectionMechanism
Session fixationSession ID regenerated on Auth::login()
CSRFToken regenerated on login and logout
Timing attacksBcrypt uses constant-time comparison
Cookie theftHttpOnly flag set by default (not accessible via JavaScript)
Cross-site requestsSameSite=Lax cookie attribute by default
HTTPS enforcementSecure cookie flag on by default
Session hijackingDatabase-backed sessions with configurable lifetime
Session expiryDual idle + absolute timeouts per OWASP
Session invalidationDirect DB deletion on password change via Auth::logout_other_devices()
Password storageBcrypt with cost factor 12 (adaptive hashing)