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_at column 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
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!