Session
Server-side session store with idle + absolute timeouts, encrypted at rest, sealed columns, audit on every login / logout, and cluster-aware leader gating.
The session id (sid) is a 32-byte random value returned to the caller once and stored client-side (cookie / authorization header). The DB primary key is sha3('bj-session:' || sid) — the plaintext sid never lands in the database. DB exfiltration alone cannot impersonate a session: the attacker would also need the original sid the user holds. The data column is vault-sealed JSON; userId is sealed; userIdHash indexes for destroyAllForUser without unsealing every row.
Idle + absolute timeout enforcement follows OWASP ASVS 5.0 §3.3 and NIST SP 800-63B-4. Defaults: idle 30 minutes, absolute 12 hours. Both shorten the effective lifetime even when the operator picked a long ttlMs; repeated touch({ extendBy }) cannot push expiresAt past the absolute ceiling.
Storage placement is mode-driven: single-node lives in the framework's main DB under _blamejs_sessions (baked into db.js's schema — apps cannot opt out); cluster mode lives in external-db under the same name. clusterStorage.execute routes by cluster.isClusterMode(); this module does not branch on mode.
Cluster posture per blamejs-cluster-spec.md: create / destroy / destroyAllForUser / touch / rotate / purgeExpired are leader-only (gated by cluster.requireLeader at call entry); verify and count run anywhere.
Optional fingerprint binding: pass { req, fingerprintFields } to create and verify to bind a session to a stable hash of client-IP / user-agent / accept-language. Drift produces an audit event and surfaces as fingerprintDrift: true; strict operators pass requireFingerprintMatch: true (or a maxAnomalyScore threshold with a scorer) to refuse the session on drift.
b.session.stores.localDbThin(opts) #
{
{
file: string, // required absolute path
recovery?: "refuse" | "rename-and-recreate", // forwards to b.localDb.thin
pragmas?: object, // extra PRAGMA overrides
audit?: boolean, // localDb.thin audit emission
}
}
Returns a session-store adapter backed by a dedicated b.localDb.thin SQLite file. The adapter exposes execute(sql, params) and executeOne(sql, params) — the contract b.session consumes — so passing it to b.session.useStore(store) redirects every session read/write to the isolated file without touching the framework's main DB.
Typical use is to point file at tmpfs (/dev/shm/sessions.db on Linux, an in-memory volume on Windows) so session inserts don't fight the main DB's WAL fsync + encrypted-at-rest re-flush cycle. The adapter creates the schema on first open, so no manual migration is required.
var b = require("@blamejs/core");
var store = b.session.stores.localDbThin({ file: "/dev/shm/sessions.db" });
b.session.useStore(store);
// From here on every b.session.* call routes through the tmpfs file.
b.session.create(opts) #
{
{
userId: string, // required — opaque user id (sealed at rest)
data?: object, // optional sealed JSON payload
ttlMs?: number, // session lifetime; default 7d, max ~10y
req?: IncomingMessage, // bind fingerprint to this request's signals
fingerprintFields?: Array, // default ["clientIp","userAgent","acceptLanguage"]
}
}
Mint a fresh session for a known userId and return the plaintext sid the caller stores client-side (cookie / authorization header). The sid is 32 random bytes (256-bit entropy floor); the DB stores sha3('bj-session:' || sid) so DB exfiltration alone cannot impersonate the session. data is vault-sealed JSON; userId is sealed; a derived userIdHash indexes for fast destroyAllForUser. Leader-only — followers raise NotLeaderError.
Pass { req, fingerprintFields } to bind the session to a stable hash of client-IP / user-agent / accept-language; the binding is checked on every verify call.
var s = await b.session.create({
userId: "user-42",
data: { roles: ["admin"] },
ttlMs: b.constants.TIME.hours(8),
});
res.setHeader("Set-Cookie", "sid=" + s.token + "; HttpOnly; Secure; SameSite=Strict");
// → { token: "9f2c…", expiresAt: 1735689600000 }
b.session.verify(token, opts?) #
{
{
idleTimeoutMs?: number, // default 30m; 0 disables
absoluteTimeoutMs?: number, // default 12h; 0 disables
req?: IncomingMessage, // for fingerprint check
fingerprintFields?: Array,
requireFingerprintMatch?: boolean, // strict — drift kills the session
maxAnomalyScore?: number, // 0..1; drift above kills
scorer?: function, // ({storedHash,currentInputs,currentHash,sessionAge}) -> 0..1
}
}
Look up a session by its plaintext sid, enforce TTL + idle + absolute timeouts, optionally check fingerprint drift, and return the unsealed payload. Returns null for unknown / expired / idle- expired / absolute-expired sessions; runs anywhere (leader or follower). On expiry, leader nodes best-effort delete the row; followers skip cleanup.
idleTimeoutMs defaults to 30 minutes, absoluteTimeoutMs to 12 hours; pass 0 to disable either floor. Pass { req } to evaluate the bound fingerprint — the result carries fingerprintDrift: true on mismatch (audit event always fires). requireFingerprintMatch: true or a maxAnomalyScore threshold (with a scorer callback) makes drift refuse the session by returning null. A strict policy also refuses (returns null) a session that carries no comparable binding — one created without { req }, or whose sealed binding cannot be decrypted — since the device match cannot be proven; bind every session you intend to verify strictly by passing { req } to create.
var info = await b.session.verify(req.cookies.sid, { req: req });
if (!info) {
res.statusCode = 401;
res.end("login required");
return;
}
var userId = info.userId;
var roles = (info.data && info.data.roles) || [];
// → { userId: "user-42", data: { roles: ["admin"] }, createdAt: ..., expiresAt: ..., lastActivity: ..., fingerprintDrift: false, fingerprintAnomalyScore: null }
b.session.destroy(token) #
Revoke a single session by sid. Returns true when a row was deleted, false when the sid is unknown / already gone / empty. Standard logout flow: clear the client's cookie AND call destroy(sid) so the row vanishes from the DB and verify(sid) starts returning null cluster-wide. Leader-only.
await b.session.destroy(req.cookies.sid);
res.setHeader("Set-Cookie", "sid=; HttpOnly; Max-Age=0");
res.end("logged out");
// → true
b.session.logout(res, token, opts?) #
{
cookieName: string, // default: "sid" — the session cookie to expire
types: string[], // default: the RFC 9527 Clear-Site-Data directive set
}
Secure logout in one call: destroy the server-side session AND tell the browser to wipe its client-side state. It emits an RFC 9527 Clear-Site-Data response header (cookies + storage + cache + executionContexts by default) and expires the session cookie, then destroys the session row. destroy() alone is a store operation with no res, so it cannot wipe the browser's cached pages / storage / any stale tab still holding the now-revoked cookie; this composes the secure-default logout the middleware otherwise had to be mounted by hand. Returns whether a session was destroyed. Leader-only.
app.post("/logout", async function (req, res) {
await b.session.logout(res, req.cookies.sid);
res.end("logged out");
});
// → emits Clear-Site-Data + expires the sid cookie + destroys the session
b.session.destroyAllForUser(userId) #
Revoke every active session for a userId at once. Returns the count of rows deleted. Use after password change, role revocation, compromised-account reports, or "log me out everywhere" UI flows. Lookup goes through the derived userIdHash — no row needs unsealing to find matches. Leader-only.
var revoked = await b.session.destroyAllForUser("user-42");
b.audit.emit({ action: "auth.session.revoke_all", outcome: "success",
metadata: { userId: "user-42", count: revoked } });
// → 3
b.session.touch(token, opts) #
{
{
extendBy?: number, // ms to set new expiresAt = now + extendBy
}
}
Refresh lastActivity (resets the idle-timeout countdown) and optionally extend expiresAt. Returns true when a non-expired row was updated, false when the sid is unknown or the row is already past its TTL. Pass extendBy to push expiresAt forward relative to NOW (not the existing expiry — soaked sessions with continuous traffic don't accumulate unbounded expiry); the framework's MAX_TTL_MS bound applies. Leader-only.
// Bump idle clock on every request:
await b.session.touch(req.cookies.sid);
// Sliding-window: extend by another 8 hours when activity continues.
await b.session.touch(req.cookies.sid, { extendBy: b.constants.TIME.hours(8) });
// → true
b.session.rotate(oldToken, opts) #
{
{
data?: object, // replacement session data (re-sealed)
ttlMs?: number, // new TTL; if absent, existing expiresAt preserved
reason?: string, // audit metadata ("login", "mfa", "role-change")
req?: IncomingMessage, // re-key the device fingerprint to the new sid
fingerprintFields?: Array, // default ["clientIp","userAgent","acceptLanguage"]
idleTimeoutMs?: number, // idle floor (default 30m; 0 disables)
absoluteTimeoutMs?: number, // absolute floor (default 12h; 0 disables)
}
rotate() enforces the SAME idle/absolute timeout floor verify() does and
fails closed (returns null + deletes) on a session past it — a rotate must
never resurrect a session verify() would expire. Pass idleTimeoutMs /
absoluteTimeoutMs consistently with the values used at verify() (the policy
is per-call): a deployment that disables the idle floor via
verify(token, { idleTimeoutMs: 0 }) must pass the same here, or a
long-idle-but-valid session is purged on rotation.
}
Session-fixation defense: generate a fresh sid for the same userId + data, atomically replacing the old sid in the row. Call after every auth state change (login from anonymous, multifactor verified, role escalation) so any sid an attacker planted pre-login becomes invalid. Returns { token, expiresAt } on success, null when the old token is unknown / expired (operator distinguishes by checking for null). Leader-only.
Atomicity: a single WHERE-guarded UPDATE swaps sidHash. The old and new tokens never coexist — the moment the UPDATE commits, only the new token verifies. Audit event auth.session.rotate fires best-effort with metadata.reason.
Device binding: when the session was created with { req, fingerprintFields } the bound fingerprint is keyed to the sid, so rotation re-keys it to the new sid from the live request. Pass the same { req, fingerprintFields } to rotate — a fingerprint-bound session rotated without req throws, because the binding cannot follow the sid otherwise (it would silently break or make the next verify falsely report drift).
var rotated = await b.session.rotate(req.cookies.sid, {
ttlMs: b.constants.TIME.hours(8),
reason: "mfa",
});
if (rotated) {
res.setHeader("Set-Cookie", "sid=" + rotated.token + "; HttpOnly; Secure; SameSite=Strict");
}
// → { token: "7a1e…", expiresAt: 1735689600000 }
b.session.updateData(token, data, opts?) #
{
{
merge?: boolean, // default false (full replace)
touchLastActivity?: boolean, // default true
idleTimeoutMs?: number, // idle floor (default 30m; 0 disables)
absoluteTimeoutMs?: number, // absolute floor (default 12h; 0 disables)
}
updateData() enforces the SAME idle/absolute timeout floor verify() does and
fails closed (returns false + deletes) on a session past it — a write must
not resurrect a session verify() would expire. The floor policy is per-call:
pass idleTimeoutMs / absoluteTimeoutMs consistently with the values used at
verify(), or a long-idle-but-valid session (e.g. one accepted under
verify(token, { idleTimeoutMs: 0 })) is purged on the next write.
}
Update the sealed data payload on a session WITHOUT rotating the sid. Use cases: cart-state writes, user-preference flips, step-up- auth completion flags, fingerprint-anomaly score updates. Anything that doesn't change the security boundary (login transition, role escalation, multifactor verified) — those still go through b.session.rotate({ data }) so the sid moves and any pre-login tokens an attacker may have planted become invalid.
Default semantics: - data REPLACES the existing payload (full overwrite). The reserved __bj_fingerprint key is preserved automatically so fingerprint-binding survives the update. - lastActivity is bumped (idle-timeout reset) unless opts.touchLastActivity: false. - The session must be live (not expired) for the write to land; returns false for unknown / expired tokens.
Pass opts.merge: true to deep-merge top-level keys into the existing payload instead of replacing — useful for incremental writes where the operator doesn't want to round-trip read+merge themselves. Inner objects merge ONE LEVEL DEEP; arrays REPLACE.
Leader-only.
// Replace the data payload entirely.
await b.session.updateData(req.cookies.sid, { roles: ["admin"], theme: "dark" });
// Merge a single field without disturbing the rest of the payload.
await b.session.updateData(req.cookies.sid,
{ stepUpAt: Date.now() }, { merge: true });
// → true
b.session.purgeExpired() #
Bulk-delete every row whose expiresAt is in the past. Returns the count of rows removed. The framework purges opportunistically on verify (leader-side), but a periodic sweep keeps the table from accumulating dead rows when verify traffic is sparse. Safe to schedule on a recurring timer (the framework's scheduler primitive is the intended caller). Leader-only.
// Hourly purge from a scheduler:
b.scheduler.every(b.constants.TIME.hours(1), async function () {
var dropped = await b.session.purgeExpired();
b.audit.emit({
action: "auth.session.purge_expired", outcome: "success",
metadata: { dropped: dropped },
});
});
// → 17
b.session.count() #
Return the number of currently-live sessions (rows whose expiresAt is in the future). Useful for ops dashboards, capacity tracking, and "active users" metrics. Runs anywhere — leader or follower — because it only reads. Note that idle-timeout-eligible rows are still counted until a verify or purgeExpired removes them; the value is an upper bound on truly-active sessions.
var live = await b.session.count();
b.observability.event({ name: "session.live", value: live });
// → 482
b.session.useStore(store) #
Replace the default _blamejs_sessions storage backend (the framework's main DB / external DB via cluster-storage) with an operator-supplied store. The store must expose execute(sql, params) and executeOne(sql, params) returning the same { rows, rowCount } / row | null shape b.clusterStorage returns. Pass null to revert to the default.
Typical use is to point session writes at an isolated SQLite file (often tmpfs) so session churn doesn't fight the main DB's encrypted- at-rest re-flush cycle. The first-party adapter is b.session.stores.localDbThin({ file }).
Call this once at boot, BEFORE the first session.create / session.verify. Switching stores on a running app strands every existing session in the old store.
var b = require("@blamejs/core");
await b.vault.init({ dataDir: "/var/lib/blamejs", mode: "plaintext" });
await b.db.init({ dataDir: "/var/lib/blamejs" });
var sessionStore = b.session.stores.localDbThin({ file: "/dev/shm/sessions.db" });
b.session.useStore(sessionStore);
// Every b.session.* call now routes through the tmpfs file.
b.session.isAnonymous(userId) #
Returns true if the supplied userId was minted by b.session.create({ anonymous: true }) (i.e., starts with the anon: prefix). Operators use this to gate post-auth behavior (e.g., refuse a payment confirmation when the session is still anonymous, or render the "log in to continue" banner).
var info = await b.session.verify(req.cookies.sid);
if (info && b.session.isAnonymous(info.userId)) {
res.statusCode = 401; res.end("login required"); return;
}
b.session.bump(subjectId, opts?) #
{
epochMs: number, // boundary to set; default Date.now(). Tokens with iat < this are revoked.
}
Revoke every STATELESS self-validating token (sealed cookie carrying no DB row, JWT) for a subject by raising a durable per-subject valid-from boundary to now. Any token whose issued-at (iat) predates the boundary fails b.session.check. Unlike destroyAllForUser — which deletes server-side session rows — this revokes tokens the framework never stored a row for: log-out-everywhere, a right-to-erasure cutoff, a forced re-auth after a password / key change. destroyAllForUser calls this for you, so a single "logout everywhere" covers both store-backed and stateless tokens.
The boundary is MONOTONIC: it only ever moves forward. A bump to an epochMs at or below the stored value is a no-op — a replayed or clock-skewed lower value can never widen a revoked window back open. Returns the boundary in effect after the call. Leader-only. The subject id is stored hashed; the plaintext id never lands in the table.
// Force re-auth everywhere for a subject after a password change:
var boundary = await b.session.bump("user-42");
// Cut off at a specific instant (right-to-erasure effective time):
await b.session.bump("user-42", { epochMs: erasureEffectiveMs });
b.session.validFrom(subjectId) #
Read the current valid-from boundary (epoch ms) for a subject. Returns 0 when the subject has never been bumped — no token is revoked by boundary, so any non-negative token iat passes b.session.check. Runs anywhere (leader or follower) — it only reads. The subject id is hashed before lookup; the plaintext id never lands in the table.
var boundary = await b.session.validFrom("user-42");
// → 1735689600000 (last bump) or 0 (never bumped)
b.session.check(subjectId, tokenIatMs) #
Decide whether a stateless self-validating token is still valid against the subject's valid-from boundary. Returns true when the token's issued-at (tokenIatMs, epoch ms) is at or after the boundary; false when the token was issued before the last bump (revoked). A subject that was never bumped has boundary 0, so any non-negative iat is valid. Runs anywhere.
Fails CLOSED: a non-finite / negative / non-number tokenIatMs returns false (treat an unparseable token as revoked rather than admit it). Call this AFTER the token's own signature + expiry checks pass — it is the server-side revocation layer those stateless checks otherwise lack.
// jwt already signature- and exp-verified; iat is in seconds → ms:
var ok = await b.session.check(claims.sub, claims.iat * 1000);
if (!ok) { res.statusCode = 401; res.end("session revoked"); return; }
Last updated 2026-08-08T16:39:49.652Z by seeder.