Skip to content
Night Owls.dev
Jump to a page

@nightowlsdev/auth-sessions

Auth

A 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-sessions

Key exports

  • makeSessionStore
  • nightOwlsPlugin
  • MIGRATIONS

Usage

auth-sessions.ts
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.

auth-sessions-example-1.ts
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.

auth-sessions-example-2.ts
// 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-jwtMints the jti-bearing session tokens this store makes revocable.
  • auth-supabasePair it to add revocation on top of Supabase-mapped identity.
  • auth-auth0Pair it to add revocation Auth0's offline JWKS verification cannot provide on its own.
  • cliowl install auth-sessions ejects the shipped table migration into your host.
  • coreDefines the AuthContext this session's identity ultimately populates.