Session
The session module provides a unified server-side session backed by a pluggable session store and an HTTP-only cookie. A singleSession class handles both anonymous visitors and authenticated users.
The storage backend is pluggable: @strav/kernel defines the SessionStore interface and @strav/database ships two implementations — PostgresSessionStore (default) and RedisSessionStore. Choose at provider registration time.
Setup
Using a service provider (recommended)
import { SessionProvider } from '@strav/http'
import { RedisProvider } from '@strav/database'
// Postgres (default)
app.use(new SessionProvider())
// Redis
app.use(new RedisProvider())
app.use(new SessionProvider({ driver: 'redis' }))
SessionProvider registers SessionManager as a singleton, resolves the store for the configured driver, and plugs it in. It depends on database (and on redis when driver: 'redis').
Options:
| Option | Default | Description |
|---|---|---|
driver |
'postgres' |
'postgres' or 'redis' — selects the backing store |
ensureSchema |
true |
For Postgres, auto-create _strav_sessions. No-op for Redis (uses native TTL). |
Manual setup
import { SessionManager } from '@strav/http'
import { PostgresSessionStore } from '@strav/database'
app.singleton(SessionManager)
app.singleton(PostgresSessionStore)
app.resolve(SessionManager)
const store = app.resolve(PostgresSessionStore)
SessionManager.useStore(store)
await store.ensureSchema()
Configuration
// config/session.ts
import { env } from '@strav/kernel'
export default {
driver: env('SESSION_DRIVER', 'postgres') as 'postgres' | 'redis',
cookie: 'strav_session', // cookie name
lifetime: 120, // minutes
httpOnly: true,
secure: env.bool('APP_SECURE', true),
sameSite: 'lax' as const,
}
The driver field is informational for SessionManager.config — the actual driver is chosen when you construct SessionProvider. Pass the same value to both so they stay in sync:
const driver = config.get('session.driver') as 'postgres' | 'redis'
app.use(new SessionProvider({ driver }))
Redis-backed sessions
See the @strav/database Redis documentation for client setup. In short:
# .env
SESSION_DRIVER=redis
REDIS_URL=redis://localhost:6379
import { RedisProvider } from '@strav/database'
import { SessionProvider } from '@strav/http'
app
.use(new RedisProvider())
.use(new SessionProvider({ driver: 'redis' }))
RedisSessionStore leans on Redis TTL (set from session.lifetime), so expired sessions are evicted automatically — SessionManager.gc() is a no-op for Redis.
session() middleware
Thesession() middleware runs on every request and handles the full lifecycle:
- Reads the session cookie and loads the session from the store.
- Creates a new anonymous session if absent or expired.
- Ages flash data so previous-request flash values are readable.
- Sets
ctx.get('session')andctx.get('csrfToken')for downstream handlers. - After the handler: saves dirty data and refreshes the cookie (sliding expiration).
import { router } from '@strav/http'
import { session } from '@strav/http'
// Apply globally
router.use(session())
// Or per group
router.group({ middleware: [session()] }, (r) => {
r.get('/cart', showCart)
})
Data bag
Store arbitrary key-value data in the session:
import type { Session } from '@strav/http'
router.post('/cart/add', async (ctx) => {
const s = ctx.get<Session>('session')
const { itemId } = await ctx.body<{ itemId: string }>()
const cart = s.get<string[]>('cart', [])
cart.push(itemId)
s.set('cart', cart)
return ctx.redirect('/cart')
})
Methods
| Method | Description |
|---|---|
get<T>(key, default?) |
Read a value (returns default if absent) |
set(key, value) |
Write a value (marks session dirty) |
has(key) |
Check if a key exists |
forget(key) |
Delete a key |
flush() |
Clear all data |
all() |
Return all user-facing data (excludes flash internals) |
Data is only persisted when the session is dirty (something was modified). Read-only requests incur no store writes.
Flash data
Flash data is available only on the next request — useful for success messages, error messages, and form validation feedback.// Request 1: set flash
s.flash('success', 'Item added to cart!')
s.flash('errors', { email: 'Invalid email address' })
// Request 2: read flash
s.getFlash('success') // 'Item added to cart!'
s.hasFlash('success') // true
// Request 3: gone
s.getFlash('success') // undefined
How it works
Flash uses a two-bucket system (_flash and _flash_old). At the start of each request, the middleware calls ageFlash() which moves current flash to "old" (readable this request) and clears the current bucket. The old bucket is stripped before saving, so stale flash data doesn't persist.
Authentication
The session supports optional user association. See the auth guide for the full authentication flow.// Login — associate a user with the session
s.authenticate(user) // accepts BaseModel instance or raw ID
await s.regenerate() // new session ID (prevents fixation attacks)
// Check auth status
s.isAuthenticated // true if userId is set
s.userId // string | null
// Logout — clear user association
s.clearUser()
// Or destroy the session entirely:
Session.destroy(ctx, ctx.redirect('/login'))
Session lifecycle
regenerate()
Creates a new session ID and CSRF token while preserving all data. Use after login to prevent session fixation attacks.
await s.regenerate()
save()
Persists session data via the configured store. No-op if the session hasn't been modified. Called automatically by thesession() middleware after each request.
touch()
Refreshes the session's activity marker without rewriting data (SQLUPDATE for Postgres, EXPIRE for Redis). Called by the auth() middleware after authenticated requests.
isExpired()
Checks whether the session has exceeded the configured lifetime based onlast_activity.
destroy()
Deletes the session from the store and clears the cookie:
const response = ctx.redirect('/login')
return Session.destroy(ctx, response)
Garbage collection
For SQL-backed stores, expired sessions remain in the table until cleaned up:
import { SessionManager } from '@strav/http'
const deleted = await SessionManager.gc()
console.log(`Cleaned up ${deleted} expired sessions`)
Call this periodically via a cron job, CLI command, or timer. For Redis-backed sessions this returns 0 — keys expire on their own via TTL.
Pluggable stores
SessionStore is an interface in @strav/kernel:
import type { SessionStore, SessionRecord } from '@strav/kernel'
interface SessionStore {
ensureSchema?(): Promise<void>
find(id: string): Promise<SessionRecord | null>
save(record: SessionRecord): Promise<void>
destroy(id: string): Promise<void>
touch(id: string): Promise<void>
gc(cutoff: Date): Promise<number>
}
Built-in implementations live in @strav/database:
| Store | Backend | gc() |
Notes |
|---|---|---|---|
PostgresSessionStore |
_strav_sessions table |
deletes old rows | Default. Uses INSERT ... ON CONFLICT. |
RedisSessionStore |
strav:session:<id> keys |
no-op (native TTL) | TTL synced from session.lifetime. |
Custom store
Implement SessionStore and plug it in before the session middleware runs:
import type { SessionStore, SessionRecord } from '@strav/kernel'
import { SessionManager } from '@strav/http'
class MemcachedSessionStore implements SessionStore {
async find(id: string): Promise<SessionRecord | null> { /* ... */ }
async save(record: SessionRecord): Promise<void> { /* ... */ }
async destroy(id: string): Promise<void> { /* ... */ }
async touch(id: string): Promise<void> { /* ... */ }
async gc(_cutoff: Date): Promise<number> { return 0 }
}
SessionManager.useStore(new MemcachedSessionStore())
Middleware stacks
Anonymous: session() → handler
Auth: session() → auth() → handler
Auth + CSRF: session() → auth() → csrf() → handler
Login page: session() → guest('/dashboard') → handler
Login POST: session() → csrf() → handler (anonymous CSRF works!)
Postgres table (driver: 'postgres')
_strav_sessions| Column | Type | Notes |
|---|---|---|
| id | UUID | Primary key |
| user_id | VARCHAR(255) | Nullable — null for anonymous visitors |
| csrf_token | VARCHAR(64) | Random hex, one per session |
| data | JSONB | Arbitrary key-value data |
| ip_address | VARCHAR(45) | From X-Forwarded-For |
| user_agent | TEXT | From User-Agent header |
| last_activity | TIMESTAMPTZ | Updated on save / touch |
| created_at | TIMESTAMPTZ |
Redis keys (driver: 'redis')
Each session is stored as a single JSON-serialized value under strav:session:<uuid> with a TTL equal to session.lifetime * 60 seconds. touch() refreshes the TTL; no secondary indexes or cleanup tasks are needed.