Sessions & Authentication Guide
This guide covers user sessions, authentication, and authorization in Varel applications.
Table of Contents
Sessions
Note: Session and auth imports:
import leafscale.varel.app as varel_app
import leafscale.varel.http
import leafscale.varel.middleware
import leafscale.varel.session
import leafscale.varel.auth
import leafscale.varel.vareldb
What Are Sessions?
Sessions maintain state across HTTP requests:
- Store user ID after login
- Remember shopping cart items
- Track preferences
- Maintain CSRF tokens
Session Storage (PostgreSQL-Backed)
Varel uses PostgreSQL-backed sessions with JSONB storage.
Benefits:
- ✅ Eliminates V's map.clone() crashes
- ✅ No 4KB cookie size limit - unlimited session data
- ✅ Sessions survive server restarts - database-backed persistence
- ✅ Queryable session data - JSONB + GIN indexes enable fast JSON queries
- ✅ Simpler codebase - 35% code reduction, one storage path
Configuring Sessions
import leafscale.varel.session
import leafscale.varel.middleware
import leafscale.varel.vareldb
fn main() {
mut app := varel_app.new('My App')
// Connect to PostgreSQL (single connection, lazy initialization)
mut db := vareldb.connect(vareldb.Config{
host: 'localhost'
port: 5432
database: 'myapp_dev'
user: 'postgres'
password: ''
})!
// Configure PostgreSQL-backed sessions
session_config := session.Config{
cookie_name: 'varel_session' // Cookie stores session ID only
max_age: 7 * 24 * 3600 // 7 days
secure: true // HTTPS only (recommended)
http_only: true // Not accessible via JavaScript
same_site: .strict // CSRF protection
db_conn: unsafe { &db } // Database connection (required)
}
// Create session middleware (PostgreSQL-backed)
mut session_mw := middleware.new_session_middleware(session_config, mut db)!
app.use_middleware(session_mw)
app.get('/', index)!
app.listen(':8080')
}
Key Points:
- Session data stored as JSONB in PostgreSQL (queryable, validated)
- Cookie contains session ID only (64-char hex, 32 bytes random)
- No encryption needed - sensitive data stays in database
updated_atcolumn auto-updated by PostgreSQL trigger
Database Schema
Varel automatically creates the sessions table via migration:
CREATE TABLE sessions (
id VARCHAR(64) PRIMARY KEY, -- Session ID (cookie value)
data JSONB NOT NULL DEFAULT '{}'::jsonb, -- Session data (queryable)
user_id INTEGER, -- Authenticated user ID
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- GIN index for fast JSON queries
CREATE INDEX idx_sessions_data ON sessions USING GIN (data);
-- Auto-update trigger for updated_at
CREATE TRIGGER update_sessions_updated_at
BEFORE UPDATE ON sessions
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Using Sessions
// Set authenticated user and session data
pub fn login(mut ctx http.Context) http.Response {
// MUST use ctx.set_user() to set authentication state
ctx.set_user(user.id)
// Store additional session data
ctx.session_set('logged_in_at', time.now().str())
ctx.session_set('ip_address', ctx.client_ip())
return ctx.redirect('/dashboard')
}
// Check authentication and get user ID
pub fn dashboard(mut ctx http.Context) http.Response {
// Check if authenticated
if !ctx.is_authenticated() {
return ctx.redirect('/login')
}
// Get authenticated user ID
user_id := ctx.user_id() or {
return ctx.redirect('/login')
}
// Load user from database
mut db := ctx.db!
user := models.User.find(db, user_id) or {
return ctx.internal_error('User not found')
}
// Get session data
logged_in_at := ctx.session_get('logged_in_at') or { 'unknown' }
return ctx.render('dashboard', {
'user': user
'logged_in_at': logged_in_at
})
}
// Logout - clear session
pub fn logout(mut ctx http.Context) http.Response {
ctx.session_clear()
return ctx.redirect_temporary('/')
}
Context API Reference
Authentication Methods
The Context object provides several methods for working with authenticated users:
Setting Authentication:
pub fn (mut ctx Context) set_user(user_id int)
Sets the authenticated user ID. This method MUST be used instead of ctx.session_set('user_id', ...) because it properly sets the session.user_id field that authentication checks rely on.
Usage:
// After successful login
ctx.set_user(user.id)
// Optionally store additional user data
ctx.session_set('username', user.username)
ctx.session_set('role', user.role)
Checking Authentication:
pub fn (ctx &Context) is_authenticated() bool
Returns true if a user is authenticated (session exists and session.user_id is set).
Usage:
if !ctx.is_authenticated() {
return ctx.redirect('/login')
}
Getting Authenticated User ID:
pub fn (ctx &Context) user_id() ?int
Returns the authenticated user's ID, or none if not authenticated.
Usage:
user_id := ctx.user_id() or {
return ctx.redirect('/login')
}
// Load user from database
mut db := ctx.db!
user := models.User.find(db, user_id)!
Session Data Storage:
pub fn (mut ctx Context) session_set(key string, value string)
pub fn (ctx &Context) session_get(key string) ?string
Store and retrieve arbitrary string data in the session. Important: These methods store data in session.data map and are NOT for authentication. Always use ctx.set_user() for authentication.
Note: Session data stored as JSONB in PostgreSQL - no map cloning issues!
Usage:
// Store user preferences
ctx.session_set('theme', 'dark')
ctx.session_set('language', 'en')
// Retrieve preferences
theme := ctx.session_get('theme') or { 'light' }
language := ctx.session_get('language') or { 'en' }
Session Management:
pub fn (mut ctx Context) session_clear()
pub fn (mut ctx Context) regenerate_session() !
session_clear()- Clears all session data (logout)regenerate_session()- Generates new session ID (security, call after login)
Authentication
User Model
// models/user.v
module models
import crypto.bcrypt
import leafscale.varel.vareldb as db
import time
pub struct User {
pub mut:
id int
username string
email string
password_hash string
created_at time.Time
updated_at time.Time
}
// Create user with hashed password
pub fn User.create(mut database db.DB, username string, email string, password string) !User {
// Hash password with bcrypt (cost 12)
password_hash := bcrypt.generate_from_password(password.bytes(), 12) or {
return error('Failed to hash password')
}
// Insert user and get returned row
rows := database.exec_params('
INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, username, email, password_hash, created_at, updated_at
', [username, email, password_hash.bytestr()])!
if rows.len == 0 {
return error('Failed to create user')
}
row := rows[0]
// Parse timestamps (PostgreSQL format: "2025-11-01 12:34:56")
created_at := time.parse_format(db.to_string(row.vals[4], '')!, 'YYYY-MM-DD HH:mm:ss') or { time.now() }
updated_at := time.parse_format(db.to_string(row.vals[5], '')!, 'YYYY-MM-DD HH:mm:ss') or { time.now() }
return User{
id: db.to_int(row.vals[0])!
username: db.to_string(row.vals[1], '')!
email: db.to_string(row.vals[2], '')!
password_hash: db.to_string(row.vals[3], '')!
created_at: created_at
updated_at: updated_at
}
}
// Authenticate user
pub fn User.authenticate(mut database db.DB, username string, password string) ?User {
rows := database.exec_params('
SELECT id, username, email, password_hash, created_at, updated_at
FROM users
WHERE username = $1
', [username]) or {
return none
}
if rows.len == 0 {
return none
}
row := rows[0]
// Parse user data
password_hash := db.to_string(row.vals[3], '')!
created_at := time.parse_format(db.to_string(row.vals[4], '')!, 'YYYY-MM-DD HH:mm:ss') or { time.now() }
updated_at := time.parse_format(db.to_string(row.vals[5], '')!, 'YYYY-MM-DD HH:mm:ss') or { time.now() }
user := User{
id: db.to_int(row.vals[0])!
username: db.to_string(row.vals[1], '')!
email: db.to_string(row.vals[2], '')!
password_hash: password_hash
created_at: created_at
updated_at: updated_at
}
// Verify password
bcrypt.compare_hash_and_password(password.bytes(), user.password_hash.bytes()) or {
return none
}
return user
}
// Find user by ID
pub fn User.find(mut database db.DB, id int) !User {
rows := database.exec_params('
SELECT id, username, email, password_hash, created_at, updated_at
FROM users
WHERE id = $1
', [id.str()])!
if rows.len == 0 {
return error('User not found')
}
row := rows[0]
created_at := time.parse_format(db.to_string(row.vals[4], '')!, 'YYYY-MM-DD HH:mm:ss') or { time.now() }
updated_at := time.parse_format(db.to_string(row.vals[5], '')!, 'YYYY-MM-DD HH:mm:ss') or { time.now() }
return User{
id: db.to_int(row.vals[0])!
username: db.to_string(row.vals[1], '')!
email: db.to_string(row.vals[2], '')!
password_hash: db.to_string(row.vals[3], '')!
created_at: created_at
updated_at: updated_at
}
}
Login Controller
// controllers/sessions.v
pub struct SessionsController {}
// GET /login
pub fn (c SessionsController) new(mut ctx http.Context) http.Response {
return ctx.render('sessions/new', {
'page_title': 'Login'
})
}
// POST /login
pub fn (c SessionsController) create(mut ctx http.Context) http.Response {
username := ctx.form('username')
password := ctx.form('password')
// Validate
if username == '' || password == '' {
return ctx.bad_request('Username and password required')
}
// Authenticate (get database from context)
mut db := ctx.db!
user := models.User.authenticate(mut db, username, password) or {
return ctx.unauthorized('Invalid credentials')
}
// Set authenticated user
ctx.set_user(user.id)
// Regenerate session ID (security best practice)
ctx.regenerate_session()!
return ctx.redirect_temporary('/dashboard')
}
// DELETE /logout
pub fn (c SessionsController) destroy(mut ctx http.Context) http.Response {
ctx.session_clear()
return ctx.redirect_temporary('/')
}
Login Form
<!-- views/sessions/new.html.vtpl -->
<h1>Login</h1>
<form action="/login" method="POST">
<input type="hidden" name="_csrf_token" value="${csrf_token}">
<div>
<label>Username:</label>
<input type="text" name="username" required>
</div>
<div>
<label>Password:</label>
<input type="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
<p>Don't have an account? <a href="/signup">Sign up</a></p>
Signup Controller
// GET /signup
pub fn (c UsersController) new(mut ctx http.Context) http.Response {
return ctx.render('users/new', {
'page_title': 'Sign Up'
})
}
// POST /signup
pub fn (c UsersController) create(mut ctx http.Context) http.Response {
username := ctx.form('username')
email := ctx.form('email')
password := ctx.form('password')
password_confirm := ctx.form('password_confirm')
// Validate
if username.len < 3 {
return ctx.bad_request('Username must be at least 3 characters')
}
if password.len < 8 {
return ctx.bad_request('Password must be at least 8 characters')
}
if password != password_confirm {
return ctx.bad_request('Passwords do not match')
}
// Create user (get database from context)
mut db := ctx.db!
user := models.User.create(mut db, username, email, password) or {
return ctx.internal_error('Failed to create user: ${err}')
}
// Auto-login after registration
ctx.set_user(user.id)
ctx.regenerate_session()!
return ctx.redirect('/dashboard')
}
Authorization
Middleware (Struct-Based)
Protect routes that require authentication:
// middleware/auth.v
module middleware
import leafscale.varel.http
import models
pub struct AuthMiddleware {
mut:
redirect_to string = '/login' // Where to redirect unauthenticated users
}
// Create new auth middleware
pub fn new_auth_middleware(redirect_to string) &AuthMiddleware {
return &AuthMiddleware{
redirect_to: redirect_to
}
}
// Convenience constructor with default redirect
pub fn require_auth() &AuthMiddleware {
return new_auth_middleware('/login')
}
// Implement IMiddleware interface
pub fn (mut mw AuthMiddleware) handle(mut ctx http.Context, next http.HandlerFunc) http.Response {
// Check if user is authenticated
user_id := ctx.user_id() or {
// Not logged in - redirect
return ctx.redirect(mw.redirect_to)
}
// Load user from database and store in context
mut db := ctx.db!
user := models.User.find(mut db, user_id) or {
// User not found - clear session and redirect
ctx.session_clear()
return ctx.redirect(mw.redirect_to)
}
// Store user in context for handlers to access
ctx.set('current_user', user)
return next(mut ctx)
}
Protecting Routes
fn main() {
mut app := varel_app.new('My App')
// ... database and session middleware setup ...
// Public routes
app.get('/', index)!
app.get('/login', sessions_ctrl.new)!
app.post('/login', sessions_ctrl.create)!
// Protected routes
mut dashboard := app.group('/dashboard')
mut auth_mw := middleware.require_auth()
dashboard.use_middleware(auth_mw)
dashboard.get('/', dashboard_ctrl.index)!
dashboard.get('/settings', dashboard_ctrl.settings)!
app.listen(':8080')
}
Role-Based Authorization
// models/user.v
pub fn (u User) is_admin() bool {
// Check if user has admin role
return u.role == 'admin'
}
pub fn (u User) can_edit(product Product) bool {
// User can edit if they own it or are admin
return product.user_id == u.id || u.is_admin()
}
// middleware/auth.v
pub struct AdminMiddleware {
mut:
redirect_to string = '/unauthorized'
}
pub fn require_admin() &AdminMiddleware {
return &AdminMiddleware{
redirect_to: '/unauthorized'
}
}
pub fn (mut mw AdminMiddleware) handle(mut ctx http.Context, next http.HandlerFunc) http.Response {
// Get current user (must be set by AuthMiddleware first)
user := ctx.get('current_user') or {
return ctx.unauthorized('Not authenticated')
}
// Cast to User and check admin role
if user is models.User {
if !user.is_admin() {
return ctx.forbidden('Admin access required')
}
} else {
return ctx.unauthorized('Invalid user data')
}
return next(mut ctx)
}
Usage:
// Admin-only routes (chain auth + admin middleware)
mut admin := app.group('/admin')
mut auth_mw := middleware.require_auth()
mut admin_mw := middleware.require_admin()
admin.use_middleware(auth_mw)
admin.use_middleware(admin_mw)
admin.get('/', admin_ctrl.index)!
Best Practices
1. Hash Passwords
Always hash passwords - Never store plain text:
import crypto.bcrypt
// Hash password (cost 12 recommended)
password_hash := bcrypt.generate_from_password(password.bytes(), 12)!
// Verify password
bcrypt.compare_hash_and_password(password.bytes(), password_hash.bytes())!
2. Use HTTPS
Always use HTTPS in production:
- Protects passwords in transit
- Prevents session hijacking
- Required for secure cookies
Deploy behind Caddy for automatic HTTPS.
3. Regenerate Session ID
Regenerate session ID after login to prevent session fixation:
// After successful login
ctx.set_user(user.id) // Set authenticated user
ctx.regenerate_session()! // Prevent session fixation attack
4. Set Secure Cookie Flags
session_config := session.Config{
secure: true // HTTPS only
http_only: true // Not accessible via JavaScript
same_site: .strict // CSRF protection
}
Security: Cookie contains session ID only (64-char random hex). Sensitive data stays in PostgreSQL database.
5. No Secret Rotation Needed
Session cookies contain only the session ID (not encrypted data), so secret key rotation is not required.
Security Benefits:
- ✅ No encryption keys to manage or rotate
- ✅ No HMAC signing secrets to leak
- ✅ Session ID is cryptographically random (32 bytes via crypto.rand)
- ✅ Sensitive data stays in database (not in cookies)
Best practices:
- ✅ Use
secure: true(HTTPS only) in production - ✅ Use
http_only: true(prevent JavaScript access) - ✅ Use
same_site: .strict(CSRF protection) - ✅ Regenerate session ID after login (
ctx.regenerate_session()!)
6. Validate Input
Always validate user input:
// ✅ GOOD
if username.len < 3 {
return ctx.bad_request('Username too short')
}
if password.len < 8 {
return ctx.bad_request('Password must be at least 8 characters')
}
if !email.contains('@') {
return ctx.bad_request('Invalid email')
}
7. Query Session Data (JSONB)
Take advantage of JSONB queryability:
-- Find all sessions with specific preference
SELECT * FROM sessions
WHERE data->>'theme' = 'dark';
-- Find sessions by IP address
SELECT * FROM sessions
WHERE data->>'ip_address' = '192.168.1.100';
-- Count active sessions per user
SELECT user_id, COUNT(*)
FROM sessions
WHERE expires_at > NOW()
GROUP BY user_id;
Summary
You've learned:
✅ PostgreSQL-backed session storage ✅ JSONB session data with queryable fields ✅ User authentication with bcrypt ✅ Login and signup flows ✅ Authorization middleware (struct-based) ✅ Role-based access control ✅ Security best practices
Key Benefits:
- ✅ Eliminated V's map.clone() crashes
- ✅ No 4KB cookie size limit
- ✅ Sessions survive server restarts
- ✅ Queryable session data (JSONB + GIN indexes)
- ✅ Simpler codebase (35% code reduction)
Continue to the Production Features Guide for production deployment!