@nightowlsdev/auth-sessions
AuthA small, pg-backed revocable session store, logout, log-out-everywhere, and admin kill without shortening JWT TTLs.
What it does
@nightowlsdev/auth-sessions makes otherwise-stateless session JWTs REVOCABLE: a session is live only while its `jti` row exists, is not revoked, and is not expired, giving you logout, log-out-everywhere, and admin/abuse kill without shortening the token TTL. `makeSessionStore` runs parameterized queries against a `pg` Pool you provide (pg is a peer dependency, you own the Pool), and it ships the table DDL as an installable migration (`MIGRATIONS`). Server-only. Also exports the CLI plugin manifest.
Install
pnpm add @nightowlsdev/auth-sessionsKey exports
- makeSessionStore
- nightOwlsPlugin
- MIGRATIONS
Usage
import * as pkg from "@nightowlsdev/auth-sessions";What it provides
auth-sessions makes an otherwise-stateless session JWT REVOCABLE: a session is live only while its jti row exists, is not revoked, and is not expired. That buys you logout, log-out-everywhere, and admin/abuse kill WITHOUT shortening the token's TTL. makeSessionStore runs parameterized queries against a pg Pool you provide (pg is a peer dependency — you own the Pool), and the package ships the table DDL as an installable migration (MIGRATIONS / SESSIONS_MIGRATION_SQL). Server-only.
When to use it
- You issue self-contained session JWTs (e.g. via auth-jwt) and need to invalidate one before it expires.
- You want logout on this device, log-out-everywhere for a user, and an admin/abuse kill switch.
- You are on Postgres and want to own the Pool and the table rather than depend on a session vendor.
- You want the revocation check to be a single indexed row lookup you add to your existing auth gate.
When not to
- Stateless JWTs with short TTLs are acceptable for your threat model — then you do not need a revocation store at all.
- You are not on Postgres — the store is pg-only.
- You want a full login/session framework — this is a narrow revocation store, not an AuthProvider or a login flow.
Alternatives
- Short-TTL access tokens + refreshYou can tolerate a token staying valid for its (short) lifetime and would rather avoid a per-request DB read. Revocation becomes 'wait for expiry', with a refresh flow to rotate.
- Your IdP's session revocationAuth0 or Supabase already own the session and you route every check back through them. That reintroduces the per-request round-trip this store's local lookup avoids.
- A Redis/KV denylistYou want revocation state in an in-memory store instead of Postgres. You give up the shipped migration, the RLS-on table, and the single-datastore simplicity.
Strengths
- Revocation without shortening the TTL: logout, log-out-everywhere (revokeAllForUser), and admin kill all work while the JWT itself stays long-lived.
- You own the Pool: parameterized pg queries, no vendor lock, and the store composes with any auth stack that can produce a jti.
- Ships the table DDL as an installable migration — eject it with owl install auth-sessions, or apply the exported SESSIONS_MIGRATION_SQL string yourself.
- The shipped table is RLS-on with no client policy (server-only), and create is idempotent on jti.
- deleteDead sweeps expired/revoked rows so the table does not grow unbounded, and revokeAllForUser returns the count revoked.
Limits & trade-offs
- It adds a DB read (isLive) to the auth path of every request — the deliberate cost of revocability that a pure stateless JWT avoids.
- Postgres only (pg peer dependency); no other datastore is supported.
- It is a store, not an AuthProvider — you wire isLive into your own gate, and you must mint jti-bearing sessions (auth-jwt does) for it to have anything to revoke.
- The shipped table's tenant_id references nightowls.orgs(id); adapt the FK if your tenants table differs.
- You schedule deleteDead yourself — the package does not run a background sweep.
How it works
A session JWT carries a jti (mint it with auth-jwt's mintSession). makeSessionStore({ pool, table? }) returns a SessionStore over your pg Pool. On login you create(jti, userId, tenantId, expiresAtSec) — idempotent on jti. On each request, after verifying the JWT, you call isLive(jti, nowSec), which is true only if the row exists, revoked_at is null, and expires_at is still in the future. Logout is revoke(jti); log-out-everywhere is revokeAllForUser(userId) (returns the count); periodic housekeeping is deleteDead(nowSec). The table name is trusted config interpolated directly into the SQL (identifiers cannot be bound), defaulting to the unqualified 'sessions' resolved by the Pool's search_path — or schema-qualify it as 'nightowls.sessions'.
Examples
Record a session on login, gate every request on isLive
Verify the JWT first (auth-jwt), THEN confirm the session is still live.
import { Pool } from "pg";
import { makeSessionStore } from "@nightowlsdev/auth-sessions";
import { readSessionToken, readCookie, SESSION_COOKIE } from "@nightowlsdev/auth-jwt";
const sessions = makeSessionStore({ pool: new Pool() }); // "sessions" via the pool's search_path
const now = Math.floor(Date.now() / 1000);
// On login (after minting a jti-bearing session): record it.
await sessions.create(jti, userId, tenantId, now + 7 * 24 * 60 * 60);
// On each request: verify the JWT, THEN check the session is still live.
const id = readSessionToken(readCookie(req.headers.get("cookie"), SESSION_COOKIE), SECRET, now);
if (!id?.jti || !(await sessions.isLive(id.jti, now))) return unauthorized();Revoke: logout, log-out-everywhere, housekeeping
revoke and revokeAllForUser are idempotent; deleteDead purges expired/revoked rows.
// This device: kill just this session.
await sessions.revoke(jti);
// Every device: kill all of a user's live sessions; returns the count revoked.
const revoked = await sessions.revokeAllForUser(userId);
// Housekeeping (run on a schedule): delete expired OR revoked rows.
const purged = await sessions.deleteDead(Math.floor(Date.now() / 1000));Doing the parts it doesn't support
- Installing the tableRun owl install auth-sessions to eject the migration into supabase/migrations/ and apply it with your own tooling, or import SESSIONS_MIGRATION_SQL and run it against your DB (it creates nightowls.sessions + indexes + RLS).
- Placing the table in a custom schemaPass makeSessionStore({ pool, table: 'myschema.sessions' }) to schema-qualify, or point the Pool's search_path at your schema and keep the default unqualified name. The table name is trusted config, never user input.
- Automatic dead-row cleanupThere is no in-process timer. Call deleteDead(nowSec) from a host-scheduled job (cron / Trigger.dev). A revoked-but-not-yet-expired row is already rejected by isLive, so purging early is safe.
Related
- auth-jwt — Mints the jti-bearing session tokens this store makes revocable.
- auth-supabase — Pair it to add revocation on top of Supabase-mapped identity.
- auth-auth0 — Pair it to add revocation Auth0's offline JWKS verification cannot provide on its own.
- cli — owl install auth-sessions ejects the shipped table migration into your host.
- core — Defines the AuthContext this session's identity ultimately populates.