SvelteKit session management

Session management is the part of authentication that most SaaS starters get wrong — not because it's hard, but because the common advice ("just use JWTs") papers over real operational costs. Server-side sessions with hashed tokens give you instant revocation, zero client-side token exposure, and full server control over the session lifecycle. This guide walks through the pattern as shipped in the multi-tenant SvelteKit starter.

Why server-side sessions over JWT

JWTs are stateless tokens that carry their own proof of validity. That sounds appealing until you need to revoke one. A JWT stays valid until its exp claim passes — you can't invalidate it early without adding server-side state (a blocklist or revocation table), which defeats the purpose of statelessness.

Server-side sessions solve this cleanly:

  • Instant revocation: delete the database row and the session is dead. The next request hashes to a row that doesn't exist. No blocklist, no TTL on a blacklist entry.
  • No client-side token exposure: the browser only holds a random opaque cookie. It carries no claims, no user ID, no email, no role — nothing an attacker can parse or replay in a different context.
  • Server controls the lifecycle: the server decides when sessions expire, which ones are revoked, and how many active sessions a user can have. The client has no say.

The tradeoff is a database lookup on every request. That lookup is keyed by a primary-key hash against a small, indexed table, so it is fast enough to run on every request at typical single-server SaaS scale — and it buys you instant revocation and full server control.

The session token model

The token lifecycle has three stages: generation, storage, and verification.

Generation

At login, the server generates a cryptographically random token using crypto.randomBytes(32) — 32 bytes of entropy rendered as a 64-character hex string. This is not Math.random(); it's backed by the OS CSPRNG.

Storage

The server hashes the token with SHA-256 and stores the hash in the database. The raw token is never written to any table, log, or audit trail. It exists only in the response cookie.

Verification

On each request, the server reads the cookie, hashes the raw value with SHA-256, and looks up the resulting hash in the sessions table. If the row exists and hasn't expired, the user is authenticated.

// src/lib/server/auth.ts (abridged) — token generation at login
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days

export async function createSession(db: Db, userId: string): Promise<string> {
  const token = randomBytes(32).toString('hex');
  await db.insert(sessions).values({
    id: sha256(token),              // only the hash is stored
    userId,
    expiresAtMs: Date.now() + SESSION_TTL_MS,
    createdAtMs: Date.now()
  });
  return token;
}

Session table schema

The sessions table is typed with Drizzle's column builder. Each column maps directly to a runtime concern:

import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const sessions = sqliteTable(
  'sessions',
  {
    id: text('id').primaryKey(), // sha256(token) — raw token only ever lives in the cookie
    userId: text('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    expiresAtMs: integer('expires_at_ms').notNull(),
    createdAtMs: integer('created_at_ms').notNull()
  },
  (t) => [index('sessions_user_idx').on(t.userId)]
);

The id column stores sha256(token) and is the primary key — it's unique (no two tokens hash to the same value in practice) and it's the lookup key. Only the hash is persisted; the raw token lives exclusively in the cookie. Expiry is tracked in expiresAtMs (epoch milliseconds), and the index on userId keeps per-user queries fast. The shipped schema does not collect IP or user-agent metadata.

[FACT] This schema is the actual shipped definition in src/lib/server/db/schema.ts of the multi-tenant SvelteKit starter.

Login flow: step by step

The login action does six things in sequence:

  1. Validate the incoming credentials (email + password).
  2. Generate a cryptographically random token (crypto.randomBytes(32)).
  3. Hash the token with SHA-256 and store the hash as the sessions primary key.
  4. Insert a row into the sessions table with the hash as id, the user ID, and the expiry/creation timestamps.
  5. Set an httpOnly cookie on the response with the raw token value.
  6. Redirect to the authenticated dashboard.

The raw token travels from server to browser exactly once: in the Set-Cookie header of the login response. It's never logged, never stored in a database column, never included in a JWT payload.

Request resolution: hashing the cookie every time

On every subsequent request, the session resolution follows a strict path:

  1. Read the session cookie from the request.
  2. Resolve the user via getUserBySessionToken(db, token) — it hashes the token with SHA-256, looks up the sessions row by that hash (WHERE id = sha256(token)), and inner-joins the users table.
  3. If no row exists → the session is invalid. No user is attached.
  4. If the row exists but expiresAtMs is in the past → the session has expired. No user is attached.
  5. If the row exists and hasn't expired → attach the resolved user to event.locals.user.
// src/lib/server/auth.ts (abridged) — the real verification path
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 };
}

Logout: instant revocation in one DELETE

Logout is the simplest operation in the session lifecycle — it's a single database delete:

// src/lib/server/auth.ts (abridged) — destroy a single session
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 logout route clears the cookie
cookies.delete(SESSION_COOKIE, { path: '/' });

The user's browser still holds the raw cookie value, but the next request will hash to a row that no longer exists. The session is dead. No blocklist, no TTL on a revocation entry, no background job to clean up expired blacklist records. This is the core advantage of server-side sessions: revocation is a single, immediate database operation.

Revocation strategies

The shipped kit implements one revocation pattern out of the box — per-session logout. Two more are useful extensions you add yourself; they are documented here as patterns, not shipped behavior:

Per-session delete (logout) — shipped

Delete the single session row by id = sha256(token). The user is logged out from one device. Other active sessions on other devices are unaffected.

Per-user delete-all (password change) — extension

Delete all rows matching userId. Every device is logged out simultaneously. This is the correct response to a password change, a suspected account compromise, or an admin-initiated security reset. The kit ships no such function — add it if you need it.

// Logout everywhere — e.g., after password change (extension, not shipped)
await db.delete(sessions)
  .where(eq(sessions.userId, userId));

Time-based expiry with periodic cleanup — extension

Sessions have an expiresAtMs timestamp. The verification logic checks expiry on every request. Expired rows are functionally dead — the lookup returns null — but they still occupy storage. A periodic cleanup job (cron, scheduled Worker, or a DELETE WHERE expires_at_ms < now() run at off-peak hours) removes them. There is no such job shipped in the kit.

Why hashed tokens matter

If an attacker compromises the database, the sessions table is what they see. With the hashed-token pattern, every row contains a SHA-256 hash — a one-way function. You cannot reverse a SHA-256 hash to recover the original token. The attacker would need to brute-force a 64-character hex string (2256 possibilities), which is computationally infeasible with current or near-term hardware.

Compare this to storing raw tokens: a DB breach would hand the attacker an active session for every user. No extra work needed. Hashing the token converts a catastrophic full-compromise into a non-event — the stolen data is useless without the browser cookie that pairs with it.

This is not theoretical. OWASP recommends hashing session identifiers at rest, and the pattern is standard practice in production auth systems. The multi-tenant SvelteKit starter implements it as the default — there is no code path that stores raw tokens.

SvelteKit hooks integration

The session resolution lives in hooks.server.ts, SvelteKit's request middleware. The handle function intercepts every incoming request, reads the session cookie, resolves the user, and attaches the result to event.locals. Every load function and server action downstream can then access event.locals.user without re-reading the cookie or re-querying the database.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { getUserBySessionToken, SESSION_COOKIE } from '$lib/server/auth';

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.sessionToken = event.cookies.get(SESSION_COOKIE);
  const db = getDb();
  event.locals.user = await getUserBySessionToken(db, event.locals.sessionToken);
  return resolve(event);
};

The key property: the session check happens once per request, at the top of the middleware chain. Every subsequent load function in that request's tree gets the resolved user for free. There's no per-page session check, no duplicated database calls, and no risk of forgetting to check authentication on a route.

Session renewal and lifetime

The shipped starter kit does not auto-renew sessions. A session is created at login, lives until its expiresAtMs timestamp, and is destroyed on explicit logout. This is the simplest correct behavior: the session lifetime is predictable and fully controlled by the server.

For longer-lived SaaS sessions where you want users to stay logged in across weeks of use, consider a sliding expiry window: each time the session is verified successfully, extend expiresAtMs by the configured TTL. This keeps active users authenticated indefinitely while still expiring abandoned sessions.

// Sliding window renewal (extension — not shipped by default)
if (row && row.expiresAtMs > Date.now()) {
  // Session valid — optionally extend
  const newExpiryMs = Date.now() + SESSION_TTL_MS;
  await db.update(sessions)
    .set({ expiresAtMs: newExpiryMs })
    .where(eq(sessions.id, sha256(token)));
  event.locals.user = row.user;
}

[ASSUMPTION] The sliding window is a documented extension pattern, not part of the default starter kit behavior. If you add it, be aware that the UPDATE on every request adds write overhead. At SaaS scale this is negligible, but it's a deliberate tradeoff you should evaluate.

Production checklist

  • HttpOnly flag: the cookie is invisible to JavaScript. XSS payloads running in browser context cannot read or exfiltrate the session token. This is the single most important cookie attribute.
  • Secure flag: the cookie only transmits over HTTPS. In local development you disable this, but production must enforce it — a session token sent over plain HTTP is visible to network observers.
  • SameSite=Lax: prevents cross-site request forgery on state-changing POST requests while still allowing normal navigation links to set the cookie. Lax is the correct default for session cookies.
  • Domain: scope the cookie to the exact origin serving your SaaS. Do not set a broad parent domain unless you genuinely share sessions across subdomains.
  • Session expiry: the database row expiry is the source of truth. Cookie lifetime is a transport concern, not an auth boundary. Check expiry on every request regardless of what the browser thinks.
  • Periodic cleanup (extension, not shipped): run a scheduled job that deletes expired session rows. They are harmless functionally but waste storage and slow queries if the table grows unbounded.
  • Token in logs: the raw session token must never appear in application logs, error reports, or audit trails. Log user IDs and truncated token hashes if you need to trace a session.

Related guides: Multi-tenant auth architecture · Hashed session tokens · Rate limiting

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.