Guide

Server-side session management with Postgres in SvelteKit

Server-side sessions mean the server controls the session's lifetime and can revoke it at any time — unlike a stateless JWT, which stays valid until it expires no matter what the server wants. This guide walks the exact session design in the SvelteKit + Postgres Starter: how a token is issued, why only its hash is stored, how expiry works, and how revocation maps to a simple row delete. Every claim below is grounded in src/lib/server/auth.ts, src/lib/server/db/schema.ts, and docs/architecture.md "Sessions".

The sessions table

The session row is deliberately minimal. Its primary key is not a random id — it is the SHA-256 hash of the raw session token, so the database never holds anything that alone unlocks a session:

            
src/lib/server/db/schema.ts (sessions table)
// src/lib/server/db/schema.ts (sessions table) export const sessions = pgTable( 'sessions', { id: text('id').primaryKey(), // sha256(token) — raw token only ever lives in the cookie userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), expiresAtMs: bigint('expires_at_ms', { mode: 'number' }).notNull(), createdAtMs: bigint('created_at_ms', { mode: 'number' }).notNull() }, (t) => [index('sessions_user_idx').on(t.userId)] );

Note the id column comment: "sha256(token) — raw token only ever lives in the cookie." expiresAtMs is a Unix epoch-millisecond bigint in UTC, matching the kit's portability convention (the same service layer runs unchanged on SQLite in the Multi-tenant SvelteKit Starter, so timestamps are numbers everywhere). The migration SQL in drizzle/0000_init.sql mirrors this exactly — id is a text primary key and user_id is a uuid foreign key referencing users.id with ON DELETE cascade, so deleting a user removes their sessions. The sessions_user_idx index makes "find all sessions for this user" (e.g. for an account-wide logout) efficient.

The session token lifecycle

From docs/architecture.md "Sessions": the token is 32 random bytes, hex; the cookie holds the raw token (httpOnly, sameSite=lax, secure in prod); and the database stores only sha256(token) as the primary key. The full lifecycle has five steps, implemented across auth.ts and hooks wiring:

  1. GeneratecreateSession() draws 32 random bytes with randomBytes(32) and hex-encodes them into a raw token.
  2. Store the cookie — only the raw token goes into the client cookie, flagged httpOnly (inaccessible to JavaScript), sameSite=lax (CSRF mitigation), and secure in production. The raw token never touches the database.
  3. Persist the hash — the row's primary key is sha256(token), so the DB holds a one-way hash, not the secret.
  4. Verify — on each request, hash the presented cookie and look the row up; a missing, expired, or malformed token yields no session.
  5. Revoke — delete the row. With the row gone, the same token no longer resolves, so the session is dead server-side.

Here is the exact issue/verify/revoke implementation from src/lib/server/auth.ts (real function names — createSession, getUserBySessionToken, destroySession):

            
src/lib/server/auth.ts (abridged)
// src/lib/server/auth.ts (abridged) const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days /** Issue a new session token for a user. */ export async function createSession(db: Db, userId: string): Promise<string> { const token = randomBytes(32).toString('hex'); await db.insert(sessions).values({ id: sha256(token), userId, expiresAtMs: Date.now() + SESSION_TTL_MS, createdAtMs: Date.now() }); return token; } /** Compute the hex SHA-256 digest of a string. */ export function sha256(input: string): string { return createHash('sha256').update(input).digest('hex'); } /** Resolve a session token to the authenticated user, if any. */ export async function getUserBySessionToken(db: Db, token: string | undefined) { if (!token) return null; const row = ( await db .select({ user: users, expiresAtMs: sessions.expiresAtMs }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(eq(sessions.id, sha256(token))) .limit(1) )[0]; if (!row) return null; if (row.expiresAtMs <= Date.now()) return null; return { id: row.user.id, email: row.user.email, name: row.user.name }; } /** Revoke a session, logging the user out. */ export async function destroySession(db: Db, token: string | undefined): Promise<void> { if (!token) return; await db.delete(sessions).where(eq(sessions.id, sha256(token))); }

The cookie name is defined as a constant: export const SESSION_COOKIE = 'session';. On sign-in the caller stores the raw token returned by createSession() in that cookie; on every authenticated request the app reads the session cookie and hands it to getUserBySessionToken().

Why hashing the token at rest makes a DB leak harmless

Because the sessions.id primary key is sha256(token), a database dump or SQL injection that leaks the sessions table exposes only 64-character hex digests. SHA-256 is one-way, and the raw tokens (32 random bytes) have far more entropy than anything an attacker could brute-force from the hash. Architecture.md states it plainly: a database leak doesn't yield usable sessions.

This is the same defense-in-depth the kit applies to invite tokens (raw invite tokens are hashed and only shown once) and to passwords (scrypt, never plaintext). "Secrets are stored hashed" is a standing design rule of the kit: invite tokens and session tokens exist raw only at the moment of use. The raw token is generated, handed to the browser once via the cookie, and never persisted anywhere on the server.

Expiring sessions: fixed 30 days in v0.1

The kit sets a fixed 30-day lifetime: SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 (30 days in auth.ts), stored as expiresAtMs and enforced server-side in getUserBySessionToken() with if (row.expiresAtMs <= Date.now()) return null;. Expiration is therefore not cosmetic — the server refuses expired rows even if the cookie is still in the browser.

Sliding expiry is a deliberate non-goal in v0.1. A sliding session would extend expiresAtMs on each activity, keeping active users logged in indefinitely. Architecture.md lists it under "Sessions" and docs/architecture.md/README.md note that sliding expiry is deferred until real usage data exists. The reasoning is honest: a fixed 30-day bound is predictable and safe to ship today, and it avoids accumulating effectively-live rows for inactive accounts before there is evidence a sliding window is worth the extra write on every request.

How sessions are wired through hooks and load functions

SvelteKit resolves the user once per request in hooks.server.ts, so routes and load functions never re-implement token logic. The pattern (mirroring the GUC wiring shown in docs/deployment.md for RLS, where hooks.server.ts drives per-request state):

  1. Read the session cookie from the incoming request.
  2. Call getUserBySessionToken(db, cookieValue) from auth.ts.
  3. Attach the result to locals.user (or locals.sessionUser) so inner hooks, form actions, and load functions read locals.user instead of touching the database.

Load functions then guard on locals.user — redirecting unauthenticated visitors to login and authenticated users into the app — without duplicating the lookup. This is the kit's "server-side enforcement everywhere" design rule applied to sessions: the UI hides controls, but every load/action re-checks against fresh DB state, and getUserBySessionToken queries the sessions table on every request so a stale cookie or a revoke that happened after a page render is caught on the next request.

Admin revocation: delete the row

Because sessions are fully server-side, revocation is a database delete — there is no blacklist to maintain and nothing to wait to expire. destroySession() runs db.delete(sessions).where(eq(sessions.id, sha256(token))): it hashes the presented token and deletes that exact row. The moment the row is gone, the token no longer resolves in getUserBySessionToken(), so the session is dead server-side even if the cookie remains in the browser.

Logout uses this same path (destroySession + clearing the cookie). An admin or "log out everywhere" flow would delete multiple rows by userId — which the sessions_user_idx index exists to make fast — and is a natural extension of the same primitive. The design guarantee is what matters: server-controlled lifetime, revocable by deletion. That is the property stateless JWTs cannot give you.

v0.1 limits to keep in mind

Being honest about shipped scope: the session design is solid, but two things are explicitly deferred. Sliding expiry is a deliberate non-goal (fixed 30-day bound today, revisited with real usage data). And because sessions are verified against Postgres on every request, each authenticated request pays a database round-trip — fine at starter scale and a natural candidate for a cache once you have data showing it matters. Neither changes the security model above.

Production checklist

  • Store only sha256(token) in the sessions table — never the raw token. The raw token exists in exactly two places: the randomBytes you just generated and the browser cookie.
  • Set the session cookie httpOnly, sameSite=lax, and secure in production (and clear it on logout so the browser does not keep a dead token).
  • Verify sessions by hashing the presented cookie, not by querying on a raw token or a reversible value. Expired rows return no session (getUserBySessionToken returns null for missing, expired, or malformed tokens).
  • Enforce a fixed expiry rather than relying on client-side checks. The kit ships a 30-day SESSION_TTL_MS and treats sliding expiry as a deliberate non-goal until real usage data exists.
  • Revoke by deleting the sessions row — destroySession deletes the row, and a deleted row means the token no longer resolves. That is the admin revocation flow.
  • Wire session resolution once in hooks.server.ts so every request that needs the user has it in locals without scattering token logic across routes.

Related reading

Get in touch

Questions about the product, team licenses, or anything else? We'll respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.