Guide

Hashed session tokens in SvelteKit

The standard advice for session management splits into two camps: "just use JWTs" and "store everything in the database." Both have real costs. JWTs are stateless but un-revocable without a blocklist (which defeats the purpose). Full DB sessions are revocable but expose the raw token to anyone who can read the table. There's a middle path that takes about 30 lines of code: store only sha256(token) in the database, keep the raw token exclusively in an HttpOnly cookie, and check expiry on every request.

This guide walks through the pattern as we ship it in ourmulti-tenant SvelteKit starter, where every session is DB-backed, revocable, and tested.

Why not just JWTs?

JWTs work well for short-lived API tokens between services. For user login sessions they have a structural problem: the token carries its own proof of validity, and you can't take it back without checking against some server-side state anyway. If you add a server-side revocation check, you've rebuilt DB sessions with extra steps. If you don't add one, a leaked token stays valid until it expires — and you can't set short expiries without degrading UX (constant re-logins).

The hashed-token approach gives you revocation in one DB delete, expiry enforcement on every request, and zero usable tokens in a breach — without the complexity of JWT signature verification or refresh-token rotation.

The three primitives

1. Create: generate → hash → store

// src/lib/server/auth.ts — createSession
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;               // raw token goes to the cookie only

The raw token is 32 random bytes rendered as a 64-character hex string. It never appears in the database, logs, or audit trail. If the sessions table leaks, every row is a SHA-256 hash — computationally infeasible to reverse into a usable session.

2. Verify: hash the cookie → look up → check expiry

// src/lib/server/auth.ts — getUserBySessionToken
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)))   // hash the incoming cookie
  .limit(1);
if (!row) return null;
if (row.expiresAtMs <= Date.now()) return null;  // expired
return { id: row.user.id, email: row.user.email, name: row.user.name };

The expiry check happens server-side on every request. The cookie may still exist in the browser after the DB row expires — that's fine, the lookup returns null and the user sees a login page. Don't rely on cookie expiry as your auth boundary; the DB is the source of truth.

3. Revoke: delete the row

// src/lib/server/auth.ts — destroySession
await db.delete(sessions)
  .where(eq(sessions.id, sha256(token)));

One DELETE, instant. The user's browser still has the raw cookie, but the next request hashes to a row that doesn't exist. No blocklist, no revocation token, no TTL on a blacklist entry. For "log out everywhere" you'd delete all rows for that userId — same mechanism, different WHERE clause.

The cookie details that matter

  • HttpOnly: JavaScript cannot read the cookie. XSS attacks that execute in the browser context can't exfiltrate the session token.
  • Secure: Only sent over HTTPS. You'll need to disable this locally for testing, but it must be on in production.
  • SameSite=Lax: Prevents CSRF on state-changing requests from cross-origin sites while still allowing normal navigation links to work.
  • Path=/: The session cookie applies to all routes. Don't scope it to one path unless you have a specific reason.
  • Max-Age = 30 days: The cookie is set with a 30-day Max-Age, matching the DB TTL. The DB row's expiresAtMs is still authoritative and is checked on every request.

SvelteKit integration: hooks.server.ts

The session token arrives as a cookie on every request. Inhooks.server.ts, extract it, look up the user, and attach the result to event.locals. Every load function and server action can then access the authenticated user without re-reading the cookie.

// src/hooks.server.ts (simplified)
export async function handle({ event, resolve }) {
  const token = event.cookies.get('session');
  event.locals.user = await getUserBySessionToken(db, token);
  return resolve(event);
}

The key property: getUserBySessionToken hashes the token and hits the database on every request. This is not a performance concern at typical SaaS scale — a single indexed SELECT with a 64-byte hash lookup is fast on a local SQLite database and on a properly indexed Postgres table. If you later need to optimize, the session cache layer goes between this function and the DB, not replacing the DB check.

What a DB breach gives an attacker

This is the core security property. If an attacker reads the entire sessions table, they get:

  • A list of SHA-256 hashes (the id column)
  • User IDs (the userId column)
  • Expiry timestamps

They do NOT get usable session tokens. SHA-256 is not reversible for practical purposes. The raw token exists only in the user's browser cookie (HttpOnly, so not accessible to XSS). The attacker's options are brute-force the hash (infeasible for 64-char hex strings) or compromise the user's browser directly. The DB breach alone is not enough.

Common mistakes

Storing the raw token

If you store token instead of sha256(token), a DB breach gives every attacker an active session for every user. This is the single most common session-management vulnerability. Hash the token. Always.

Using the token as the DB primary key

The primary key should be the hash, not the raw token. If you use the raw token as the key, you've stored it in the database by definition. Use a random UUID as the row ID if you prefer, and index the hash column for lookup performance.

Relying on cookie expiry instead of DB expiry

Browsers manage cookies according to their own rules. A cookie with no Max-Age persists until the browser is closed; a cookie with a long Max-Age persists longer. The DB row'sexpiresAtMs is the authoritative expiry. Check it on every request.

Logging the token

The raw session token should never appear in application logs, audit trails, or error reports. Log the user ID and session hash (or a truncated hash) if you need to trace a session. The invite token follows the same rule: our audit log records the invite ID, never the raw token.

When to rotate tokens

Token rotation (issuing a new token after each verification) is recommended for high-security applications. For most B2B SaaS, the simpler approach is sufficient: long-lived DB rows (30 days) with expiry checked per request, and instant revocation on logout. If you add rotation later, the pattern is the same: hash the new token, store it, delete the old row. The cookie update happens in the same request cycle.

The production checklist

  • Token = 32 random bytes via crypto.randomBytes, not Math.random.
  • Only sha256(token) in the DB; raw token in HttpOnly cookie only.
  • Expiry checked on every request; DB row is the source of truth.
  • Logout = DELETE the DB row; "logout everywhere" (DELETE all rows for a userId) is an extension pattern, not part of the shipped kit.
  • Cookie flags: HttpOnly, Secure, SameSite=Lax.
  • No token in logs, audit trails, or error messages.
  • Index the hash column for lookup performance.
  • Consider a periodic cleanup job for expired rows (optional; they're harmless but waste space).

Try it

This pattern ships ready-to-use in ourmulti-tenant SvelteKit starter, alongside org-level RBAC, invite flows, and an append-only audit log. 77 tests, including the session lifecycle. Clone it, runnpm install && npm test, and read the auth module — every code excerpt mirrors the actual shipped source (some simplified for focus).

Related guides: Multi-tenant auth architecture · Rate limiting · RBAC & role hierarchy

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.