Normally in web applications there are two types of login systems.
- Session Login (Normal Login)
User logs in → session cookie created → session stored in server.
Problem:
If browser closes → session expires
If server restarts → session lost
User must login again
- Persistent Login (Remember Me)
Persistent login allows:
User logs in → system stores secure token in database + cookie
Even if:
browser closes
session expires
server restarts
User can still be automatically logged in.
So the purpose of persistent login is:
Improve user experience
Reduce repeated login
Maintain security
Allow long-term authentication safely
Add security env vars
Add security env vars: SESSION_KEY, SESSION_TTL_HOURS, SESSION_SECURE_COOKIE.
SESSION_KEY=replace-with-32-byte-random-secret
SESSION_TTL_HOURS=24
# In production behind HTTPS set true. Local HTTP dev can stay false.
SESSION_SECURE_COOKIE=false
Read and validate those env vars into AppConfig fields:
src/config.rs
Read and validate those env vars into AppConfig fields:
session_ttl_hours
session_secure_cookie
pub session_ttl_hours: i64,
pub session_secure_cookie: bool,
let session_ttl_hours = env::var("SESSION_TTL_HOURS")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.filter(|v| *v > 0)
.unwrap_or(24);
// Keep local dev usable on HTTP, default secure in non-local environments.
let inferred_local = bind_addr.contains("127.0.0.1") || bind_addr.contains("localhost");
let session_secure_cookie = env::var("SESSION_SECURE_COOKIE")
.ok()
.map(|v| {
let lower = v.trim().to_ascii_lowercase();
lower == "1" || lower == "true" || lower == "yes" || lower == "on"
})
.unwrap_or(!inferred_local);
Self {
app_name,
bind_addr,
database_url,
session_ttl_hours,
session_secure_cookie,
}
full code
use std::env;
#[derive(Clone, Debug)]
pub struct AppConfig {
pub app_name: String,
pub bind_addr: String,
pub database_url: String,
pub session_ttl_hours: i64,
pub session_secure_cookie: bool,
}
impl AppConfig {
pub fn from_env() -> Self {
let app_name = env::var("APP_NAME").unwrap_or_else(|_| "Rust CRUD Dashboard".to_string());
let bind_addr = env::var("APP_BIND").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
let database_url = env::var("DATABASE_URL")
.unwrap_or_else(|_| "mysql://root:@127.0.0.1:3306/rust_crud".to_string());
let session_ttl_hours = env::var("SESSION_TTL_HOURS")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.filter(|v| *v > 0)
.unwrap_or(24);
// Keep local dev usable on HTTP, default secure in non-local environments.
let inferred_local = bind_addr.contains("127.0.0.1") || bind_addr.contains("localhost");
let session_secure_cookie = env::var("SESSION_SECURE_COOKIE")
.ok()
.map(|v| {
let lower = v.trim().to_ascii_lowercase();
lower == "1" || lower == "true" || lower == "yes" || lower == "on"
})
.unwrap_or(!inferred_local);
Self {
app_name,
bind_addr,
database_url,
session_ttl_hours,
session_secure_cookie,
}
}
}
Apply session middleware hardening:
Apply session middleware hardening:
custom cookie name
cookie_secure(...)
cookie_http_only(true)
session_lifecycle(PersistentSession...session_ttl...)
safer SESSION_KEY handling (no fixed default secret)
src/main.rs
Apply session middleware hardening:
custom cookie name
cookie_secure(...)
cookie_http_only(true)
session_lifecycle(PersistentSession...session_ttl...)
safer SESSION_KEY handling (no fixed default secret)
if valid {
session.renew();
if valid {
session.renew();
session
.insert("username", user.username.clone())
.map_err(error::ErrorInternalServerError)?;
session
.insert("user_id", user.id)
.map_err(error::ErrorInternalServerError)?;
session
.insert("role", role.clone())
.map_err(error::ErrorInternalServerError)?;
println!("[login] session_set username={}", user.username);
let response = ApiResponse {
message: "Login successful".to_string(),
data: AuthUser {
username: user.username,
role,
},
};
return Ok(HttpResponse::Ok().json(response));
}
On successful login, call session.renew() before inserting session values to reduce session fixation
src/handlers/auth.rs
use actix_session::{
config::{PersistentSession, TtlExtensionPolicy},
storage::CookieSessionStore,
SessionMiddleware,
};
use actix_web::{
cookie::{time::Duration, Key, SameSite},
web, App, HttpServer,
};
let session_key = std::env::var("SESSION_KEY").unwrap_or_else(|_| {
eprintln!("[startup] SESSION_KEY is missing; generating ephemeral key for this process.");
format!("{}{}", uuid::Uuid::new_v4(), uuid::Uuid::new_v4())
});
let mut session_key_bytes = session_key.into_bytes();
if session_key_bytes.len() < 32 {
session_key_bytes.resize(32, b'0');
}
App::new()
.app_data(state.clone())
.wrap(cors)
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), key.clone())
.cookie_name("rust_crud_session".to_string())
.cookie_secure(cfg.session_secure_cookie)
.cookie_http_only(true)
.cookie_same_site(SameSite::Lax)
.session_lifecycle(
PersistentSession::default()
.session_ttl(Duration::hours(cfg.session_ttl_hours))
.session_ttl_extension_policy(TtlExtensionPolicy::OnEveryRequest),
)
.build(),
)
full code
mod config;
mod db;
mod handlers;
mod layout;
mod middleware;
mod models;
use actix_cors::Cors;
use actix_files::Files;
use actix_session::{
config::{PersistentSession, TtlExtensionPolicy},
storage::CookieSessionStore,
SessionMiddleware,
};
use actix_web::{
cookie::{time::Duration, Key, SameSite},
web, App, HttpServer,
};
use config::AppConfig;
use db::{init_pool, normalize_brand_model_rows, normalize_shop_rows, state_data, AppState};
use dotenvy::dotenv;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
std::fs::create_dir_all("uploads/brand_models")?;
std::fs::create_dir_all("uploads/insurance")?;
std::fs::create_dir_all("uploads/pollution")?;
std::fs::create_dir_all("uploads/rc_document")?;
let cfg = AppConfig::from_env();
let pool = init_pool(&cfg.database_url)
.await
.expect("Database connection failed");
if let Err(err) = normalize_brand_model_rows(&pool).await {
eprintln!("[startup] normalize_brand_model_rows failed: {err}");
}
if let Err(err) = normalize_shop_rows(&pool).await {
eprintln!("[startup] normalize_shop_rows failed: {err}");
}
let state = state_data(AppState {
pool,
app_name: cfg.app_name.clone(),
});
let session_key = std::env::var("SESSION_KEY").unwrap_or_else(|_| {
eprintln!("[startup] SESSION_KEY is missing; generating ephemeral key for this process.");
format!("{}{}", uuid::Uuid::new_v4(), uuid::Uuid::new_v4())
});
let mut session_key_bytes = session_key.into_bytes();
if session_key_bytes.len() < 32 {
session_key_bytes.resize(32, b'0');
}
let key = Key::derive_from(&session_key_bytes);
HttpServer::new(move || {
let cors = Cors::default()
.allowed_origin("http://localhost:5173")
.allowed_origin("http://127.0.0.1:5173")
.allowed_origin("http://localhost:5174")
.allowed_origin("http://127.0.0.1:5174")
.allowed_origin("http://localhost:8080")
.allowed_origin("http://127.0.0.1:8080")
.allowed_origin("http://localhost:8081")
.allowed_origin("http://127.0.0.1:8081")
.allow_any_header()
.allow_any_method()
.supports_credentials();
App::new()
.app_data(state.clone())
.wrap(cors)
.wrap(
SessionMiddleware::builder(CookieSessionStore::default(), key.clone())
.cookie_name("rust_crud_session".to_string())
.cookie_secure(cfg.session_secure_cookie)
.cookie_http_only(true)
.cookie_same_site(SameSite::Lax)
.session_lifecycle(
PersistentSession::default()
.session_ttl(Duration::hours(cfg.session_ttl_hours))
.session_ttl_extension_policy(TtlExtensionPolicy::OnEveryRequest),
)
.build(),
)
.service(
web::scope("/api")
.route("/login", web::post().to(handlers::login))
.route("/logout", web::post().to(handlers::logout))
.route("/home/vehicles", web::get().to(handlers::list_home_vehicle_cards_handler))
.service(
web::scope("")
.wrap(middleware::AuthMiddleware)
.route("/me", web::get().to(handlers::me))
.route("/dashboard", web::get().to(handlers::dashboard))
.service(
web::scope("")
.wrap(middleware::require_roles(&["user"]))
.route("/shops", web::get().to(handlers::list_shops_handler))
.route("/shops", web::post().to(handlers::create_shop_handler))
.route("/shops/{id}", web::get().to(handlers::get_shop_handler))
.route("/shops/{id}", web::put().to(handlers::update_shop_handler))
.route("/shops/{id}", web::delete().to(handlers::delete_shop_handler))
.route("/vehicles", web::get().to(handlers::list_vehicles_handler))
.route("/vehicles", web::post().to(handlers::create_vehicle_handler))
.route(
"/vehicles/upload-documents",
web::post().to(handlers::upload_vehicle_documents_handler),
)
.route(
"/vehicles/upload-partner-image",
web::post().to(handlers::upload_vehicle_partner_image_handler),
)
.route("/vehicles/{id}", web::get().to(handlers::get_vehicle_handler))
.route("/vehicles/{id}", web::put().to(handlers::update_vehicle_handler))
.route("/vehicles/{id}", web::delete().to(handlers::delete_vehicle_handler))
.route("/bookings/offline", web::post().to(handlers::save_offline_booking_handler))
.route("/brand-models", web::get().to(handlers::list_brand_models_handler))
.route("/brand-models", web::post().to(handlers::create_brand_model_handler))
.route(
"/brand-models/upload-images",
web::post().to(handlers::upload_brand_model_images_handler),
)
.route(
"/brand-models/upload-images/",
web::post().to(handlers::upload_brand_model_images_handler),
)
.route(
"/brand-model/upload-images",
web::post().to(handlers::upload_brand_model_images_handler),
)
.route("/brand-models/{id}", web::get().to(handlers::get_brand_model_handler))
.route("/brand-models/{id}", web::put().to(handlers::update_brand_model_handler))
.route("/brand-models/{id}", web::delete().to(handlers::delete_brand_model_handler)),
),
),
)
.service(Files::new("/uploads", "./uploads"))
})
.bind(cfg.bind_addr)?
.run()
.await
}
Explanation
let session_ttl_hours = env::var("SESSION_TTL_HOURS")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.filter(|v| *v > 0)
.unwrap_or(24);
alternative way
Same Logic in Beginner Style (Long Version)
This is the same code written in a more beginner friendly style.
use std::env;
fn main() {
let value = env::var("SESSION_TTL_HOURS");
let session_ttl_hours = match value {
Ok(v) => {
match v.parse::<i64>() {
Ok(num) if num > 0 => num,
_ => 24
}
}
Err(_) => 24
};
println!("Session TTL = {}", session_ttl_hours);
}
Output is the same.
Top comments (0)