@pronghorn/session
v0.1.1
Published
Server-side session middleware for Pronghorn. Stores session data behind a signed session-ID cookie with a pluggable store, defaulting to in-memory.
Maintainers
Readme
Session 🔑
Session is a lightweight, TypeScript-first server-side session middleware built as an external plugin for Pronghorn. It stores arbitrary session data behind a signed session-ID cookie, backed by a pluggable store, and exposes a mutable context.locals.session object that persists automatically after each request.
Built as a standalone package (
@pronghorn/session), layers directly on top of@pronghorn/cookie's signing primitives, the missing piece between stateless JWT auth and full server-side session state.
Why Session
@pronghorn/cookie can read and write signed cookies, but every value still round-trips to the client, fine for small flags, unsuitable for larger or sensitive session state like cart contents, flash messages, or server-verified login state. Session solves this by keeping the actual data server-side, and only ever sending a signed, opaque session ID to the browser.
- Session data lives entirely server-side behind a pluggable
SessionStore, only a signed session ID touches the client. - Ships with a default in-memory store, swap in Redis, Prisma, or any custom store by implementing three async methods.
- New sessions are issued automatically on first use, no manual cookie bootstrapping required.
regenerate()support for session-fixation-safe login flows (rotate the ID, keep or reset the data).rollingexpiration extends session lifetime on every request, or setfalsefor a fixed absolute expiry.- Built directly on
@pronghorn/cookie's HMAC signing and timing-safe verification, no duplicated crypto logic.
Installation
bun add @pronghorn/session @pronghorn/cookieRequires Bun >=1.3.0 and pronghorn >=0.1.2 as a peer dependency (used for typing the middleware only). @pronghorn/cookie is a direct runtime dependency, its signing and parsing primitives are reused rather than reimplemented.
Quick Start
import { createApp } from 'pronghorn'
import { session } from '@pronghorn/session'
const app = createApp()
app.use(session({ secret: process.env.SESSION_SECRET ?? 'dev-secret', maxAge: 3600 }))
app.post('/login', context => {
const userSession = context.locals.session as Session
userSession.data.userId = 42
return context.json({ loggedIn: true })
})
app.get('/me', context => {
const userSession = context.locals.session as Session
return context.json({ userId: userSession.data.userId ?? null })
})
await app.listen(4000)Core Concepts
Reading and writing session data
Once session() is registered, context.locals.session.data is a plain mutable object. Any change made to it during the request is automatically persisted to the store afterward, no explicit "save" call needed.
app.post('/cart', context => {
const userSession = context.locals.session as Session
const items = (userSession.data.items as string[]) ?? []
items.push('widget-42')
userSession.data.items = items
return context.json({ items })
})First-time sessions
If no valid session cookie is present (or the signature fails verification), Session transparently creates a new empty session and issues a fresh signed cookie in the response, your handler never needs to check for this case explicitly.
app.get('/visits', context => {
const userSession = context.locals.session as Session
const visits = ((userSession.data.visits as number) ?? 0) + 1
userSession.data.visits = visits
return context.json({ visits }) // starts at 1 on first request, increments after
})Destroying a session
session.destroy() clears the store entry and empties the in-request data object, use this for logout flows.
app.post('/logout', async context => {
const userSession = context.locals.session as Session
await userSession.destroy()
return context.json({ loggedOut: true })
})Regenerating a session ID
session.regenerate() rotates the session ID while keeping the middleware's response flow intact, useful right after a successful login to prevent session-fixation attacks.
app.post('/login', async context => {
const userSession = context.locals.session as Session
await userSession.regenerate()
userSession.data.userId = 42
return context.json({ loggedIn: true })
})Rolling vs. fixed expiration
By default (rolling: true), a session's expiration is pushed forward on every request, keeping active users logged in indefinitely. Set rolling: false for a fixed expiry from creation time regardless of activity.
app.use(session({ secret: process.env.SESSION_SECRET!, maxAge: 1800, rolling: false }))Middleware Options
| Option | Type | Default | Description |
| ------------ | -------------- | --------------------- | ----------------------------------------------------------- |
| secret | string | - | Required. HMAC signing secret for the session-ID cookie |
| store | SessionStore | createMemoryStore() | Backing store for session data |
| cookieName | string | 'sid' | Name of the session-ID cookie |
| maxAge | number | 86400 (1 day) | Session lifetime in seconds |
| rolling | boolean | true | Extends expiration on every request when true |
Custom Stores
Implement SessionStore to back sessions with Redis, a database, or any storage layer. Only three async methods are required.
import type { SessionStore, SessionRecord } from '@pronghorn/session'
function createRedisStore(client: RedisClient): SessionStore {
return {
async get(sessionId) {
const raw = await client.get(`session:${sessionId}`)
return raw ? (JSON.parse(raw) as SessionRecord) : null
},
async set(sessionId, record) {
const ttlSeconds = Math.max(1, Math.floor((record.expiresAt - Date.now()) / 1000))
await client.set(`session:${sessionId}`, JSON.stringify(record), { EX: ttlSeconds })
},
async destroy(sessionId) {
await client.del(`session:${sessionId}`)
}
}
}
app.use(session({ secret: process.env.SESSION_SECRET!, store: createRedisStore(redis) }))API Reference
session(options: SessionOptions): Middleware - global middleware factory, register via app.use(session(options)).
| Locals property | Type | Description |
| ------------------------ | --------- | ------------------------------------------------- |
| context.locals.session | Session | The active session handle for the current request |
Session
| Property/Method | Type | Description |
| --------------- | ------------------------- | ------------------------------------------------------------------------ |
| id | string | The current (unsigned) session ID |
| data | Record<string, unknown> | Mutable session data, persisted automatically after the handler resolves |
| destroy() | Promise<void> | Clears the store entry and empties data |
| regenerate() | Promise<void> | Rotates the session ID, destroying the old store entry |
SessionStore
| Method | Signature | Description |
| --------- | ------------------------------------------------------------- | ---------------------------------------------------- |
| get | (sessionId: string) => Promise<SessionRecord \| null> | Loads a session record, or null if missing/expired |
| set | (sessionId: string, record: SessionRecord) => Promise<void> | Persists a session record |
| destroy | (sessionId: string) => Promise<void> | Removes a session record |
SessionRecord is { data: Record<string, unknown>; expiresAt: number }.
Architecture
Session is split into two modules on top of @pronghorn/cookie's exported primitives.
| Module | Responsibility |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| memory-store.ts | Default SessionStore implementation using an in-memory Map, with lazy expiration checks on read |
| middleware.ts | Resolves the session ID from a signed cookie, loads/creates the record, exposes the mutable Session handle, and persists + re-signs the cookie after the handler resolves |
Session ID signing and verification reuse @pronghorn/cookie's signValue/unsignValue (HMAC-SHA256 with timing-safe comparison), rather than reimplementing cryptographic logic in a second package.
Security Notes
- Always use a long, random
secret, and never commit it to source control, load it from an environment variable. - The default in-memory store does not survive process restarts and does not scale across multiple instances, use a shared store (Redis, database) in any multi-instance deployment.
- Call
regenerate()immediately after a successful login to prevent session-fixation attacks, where an attacker pre-sets a victim's session ID before authentication. - The session cookie is set with
httpOnly: trueby default, preventing client-side JavaScript from reading the raw session ID.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.
