Debug School

rakesh kumar
rakesh kumar

Posted on

Build Secure Session-Based Authentication & Protected Routes in Actix-Web (Rust) – Production Ready Guide

Why Session-Based Authentication in Rust?
Project Structure Overview
Enable Session Middleware in main.rs
Add Login Route in main.rs (Where login() is called)
Store User Data in Session After Login
Create Authentication Helper Functions
Protect Routes Using Session Check
Protect Entire Route Scope (Advanced Clean Method)
Role-Based Protection (Admin vs Viewer)
Secure Production Configuration
Testing Authentication Flow
Logout Clears Session

Why Session-Based Authentication in Rust?

In modern web applications, authentication is required to protect sensitive routes like dashboard, shops, bookings, and vehicles. Session-based authentication stores user identity securely on the server and prevents unauthorized access. Actix-Web provides actix-session middleware to handle this cleanly.

Project Structure Overview

Before writing authentication logic, we must organize the project correctly. A clean folder structure makes middleware, handlers, and DB logic easy to scale.

src/
  main.rs
  config.rs
  models.rs
  handlers.rs
  handlers/
    auth.rs
    shop.rs
    vehicle.rs
  db.rs
  db/
    common.rs
    shop.rs
    vehicle.rs
Enter fullscreen mode Exit fullscreen mode

This separation ensures:

DB logic in db/

Route logic in handlers/

Shared utilities in common.rs

Server setup in main.rs
Enter fullscreen mode Exit fullscreen mode

Step 3: Enable Session Middleware in main.rs

Sessions do not work automatically. We must configure SessionMiddleware with a secure key.
This middleware makes Session available in every handler and stores cookie session data securely using SESSION_KEY.

.wrap(
    SessionMiddleware::builder(CookieSessionStore::default(), key.clone())
        .cookie_secure(false)
        .cookie_same_site(SameSite::Lax)
        .build(),
)
Enter fullscreen mode Exit fullscreen mode

This middleware:

Stores session data in cookies

Signs cookies using SESSION_KEY

Makes session available in every handler

Step 3: Add Login Route in main.rs (Where login() is called)

This is the line that connects your URL to the login() function. Without this route, your login() function exists but nobody can call it.

web::scope("/api")
    .route("/login", web::post().to(handlers::login))
Enter fullscreen mode Exit fullscreen mode

So login endpoint becomes:

✅ POST /api/login

Step 4: Store User Data in Session After Login
Full auth.rs Login Code (Where session.insert comes from)

This is the complete function with session storage.
File: src/handlers/auth.rs
When login is successful, we store required fields in session:

session.insert("username", user.username.clone())?;
session.insert("user_id", user.id)?;
session.insert("role", user.role.clone())?;
Enter fullscreen mode Exit fullscreen mode

Why this matters:

user_id is used for data filtering

role can protect admin-only routes

username is used for display
Enter fullscreen mode Exit fullscreen mode

FULL CODE

use actix_session::Session;
use actix_web::{error, web, HttpResponse, Result};
use serde::Serialize;

use crate::db::{find_user_by_username, AppState};
use crate::models::{LoginForm, UserRole};
use super::common::ApiResponse;

#[derive(Serialize)]
struct AuthUser {
    username: String,
    role: String,
}

pub async fn login(
    body: web::Json<LoginForm>,
    state: web::Data<AppState>,
    session: Session,
) -> Result<HttpResponse> {
    println!("[login] attempt username={}", body.username);

    // 1) Find user
    let maybe_user = find_user_by_username(&state.pool, &body.username)
        .await
        .map_err(error::ErrorInternalServerError)?;

    // 2) Validate user + role
    if let Some(user) = maybe_user {
        let role = UserRole::from(user.role.as_str());
        let valid = user.password == body.password;

        println!(
            "[login] user_found username={} role={} valid={}",
            user.username, user.role, valid
        );

        // 3) Only allow admin/viewer (example)
        match (valid, role) {
            (true, UserRole::Admin) | (true, UserRole::Viewer) => {
                // ✅ 4) Store login info in session (THIS is the important part)
                session
                    .insert("username", user.username.clone())
                    .map_err(error::ErrorInternalServerError)?;
                session
                    .insert("user_id", user.id)
                    .map_err(error::ErrorInternalServerError)?;
                session
                    .insert("role", user.role.clone())
                    .map_err(error::ErrorInternalServerError)?;

                println!("[login] session_set username={}", user.username);

                // 5) Return API response
                let response = ApiResponse {
                    message: "Login successful".to_string(),
                    data: AuthUser {
                        username: user.username,
                        role: user.role,
                    },
                };
                return Ok(HttpResponse::Ok().json(response));
            }
            _ => {}
        }
    }

    println!("[login] unauthorized username={}", body.username);
    Err(error::ErrorUnauthorized("Invalid credentials"))
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Create Authentication Helper Functions

Instead of repeating validation logic everywhere, create reusable guard helpers.

pub fn ensure_login_user_id(session: &Session) -> Result<i64> {
    session_user_id(session)
        .ok_or_else(|| error::ErrorUnauthorized("Login required"))
}
Enter fullscreen mode Exit fullscreen mode

This keeps handlers clean and readable.
Now we need a single helper to check login.

File: src/handlers/common.rs
Enter fullscreen mode Exit fullscreen mode
use actix_session::Session;
use actix_web::{error, Result};
use serde::Serialize;

#[derive(Serialize)]
pub struct ApiResponse<T: Serialize> {
    pub message: String,
    pub data: T,
}

pub fn session_user_id(session: &Session) -> Option<i64> {
    session.get::<i64>("user_id").ok().flatten()
}

pub fn ensure_login_user_id(session: &Session) -> Result<i64> {
    session_user_id(session).ok_or_else(|| error::ErrorUnauthorized("Login required"))
}
Enter fullscreen mode Exit fullscreen mode

This keeps protection logic consistent across all handlers.
Step 6: Protect Routes Using Session Check

Example: Protect Shop Listing Route

pub async fn list_shops_handler(
    state: web::Data<AppState>,
    session: Session,
) -> Result<HttpResponse> {

    let user_id = ensure_login_user_id(&session)?;

    let shops = list_shops_by_vendor(&state.pool, user_id as i32)
        .await?;

    Ok(HttpResponse::Ok().json(ApiResponse {
        message: "Shops fetched".to_string(),
        data: shops,
    }))
}

Enter fullscreen mode Exit fullscreen mode

Now:

If user is not logged in → 401 Unauthorized

If logged in → Only their data is shown
Enter fullscreen mode Exit fullscreen mode

FULL CODE

use actix_session::Session;
use actix_web::{error, web, HttpResponse, Result};

use crate::db::{list_shops_by_vendor, AppState};
use super::common::{ensure_login_user_id, ApiResponse};

pub async fn list_shops_handler(
    state: web::Data<AppState>,
    session: Session,
) -> Result<HttpResponse> {
    // ✅ Protect route
    let user_id = ensure_login_user_id(&session)?;

    let vendor_id =
        i32::try_from(user_id).map_err(|_| error::ErrorBadRequest("User id out of range"))?;

    let shops = list_shops_by_vendor(&state.pool, vendor_id)
        .await
        .map_err(error::ErrorInternalServerError)?;

    Ok(HttpResponse::Ok().json(ApiResponse {
        message: "Shops fetched".to_string(),
        data: shops,
    }))
}

Enter fullscreen mode Exit fullscreen mode

Step 7: Protect Entire Route Scope (Advanced Clean Method)

Instead of protecting each route manually, you can protect route groups.

Example:

web::scope("/api")
    .route("/login", web::post().to(handlers::login))
    .service(
        web::scope("")
            .wrap(AuthMiddleware)
            .route("/shops", web::get().to(handlers::list_shops_handler))
    )
Enter fullscreen mode Exit fullscreen mode

This prevents repeating session checks.

Step 8: Role-Based Protection (Admin vs Viewer)

Because you store role in session:

let role = session.get::<String>("role")?;
Enter fullscreen mode Exit fullscreen mode

You can protect admin-only routes:

if role != "admin" {
    return Err(error::ErrorForbidden("Admin only"));
}
Enter fullscreen mode Exit fullscreen mode

Now your system supports:

Vendor routes

Admin routes

Viewer routes
Enter fullscreen mode Exit fullscreen mode

Step 9: Secure Production Configuration

In production:

Set .cookie_secure(true)

Use HTTPS

Set strong 32+ character SESSION_KEY

Enable proper CORS origins
Enter fullscreen mode Exit fullscreen mode

Example:

.cookie_secure(true)
Enter fullscreen mode Exit fullscreen mode

Step 10: Testing Authentication Flow

Login → /api/login

Check session → /api/me

Access protected route → /api/shops

Logout → /api/logout
Enter fullscreen mode Exit fullscreen mode

Step 11: Logout Clears Session

File: src/handlers/auth.rs

pub async fn logout(session: Session) -> Result<HttpResponse> {
    session.purge();
    Ok(HttpResponse::Ok().json(ApiResponse {
        message: "Logged out".to_string(),
        data: true,
    }))
}
Enter fullscreen mode Exit fullscreen mode

If session is cleared → protected routes return 401

Top comments (0)