Most authentication tutorials end at "the user is logged in". That is the easy part. The hard part is everything around it: where the password actually goes, where the session lives, what happens when someone steals a cookie, and why your "protected" endpoint still accepts requests from a random website.
A common way this goes wrong: the session token sits in localStorage, an XSS bug or a compromised dependency injects a script, and every token in every open tab can be sent to an attacker's server with one fetch. HttpOnly cookies exist to stop exactly that.
This tutorial builds an authentication API that takes the boring parts seriously: Argon2id password hashing, server-side sessions, an HttpOnly session cookie, CSRF protection that matches the architecture, and a small test suite that covers the main failure paths.
What you will build
A small JSON API with four endpoints:
| Method | Path | Purpose |
|---|---|---|
POST | /api/auth/register | Create a user and start a session |
POST | /api/auth/login | Verify credentials and rotate the session |
GET | /api/auth/me | Return the signed-in user |
POST | /api/auth/logout | Destroy the session |
Plus GET /api/csrf-token for CSRF tokens and GET /api/health.
The stack:
- Node.js 22 (LTS) with TypeScript. Tested on Node v22.23.2. Node 22 is in Maintenance LTS, with end-of-life on 2027-04-30.
- Express 5
- Argon2id for password hashing
- SQLite (via
better-sqlite3) for users and sessions -
express-sessionwith a custom SQLite store -
csrf-syncfor the synchronizer token pattern - Zod for input validation
- Vitest and
supertestfor tests
The versions below were verified against the npm registry and the local runtime while writing: express@5.3.0, express-session@1.19.0, argon2@0.45.1, better-sqlite3@13.0.3, zod@4.6.7, helmet@8.3.0, csrf-sync@4.2.1, supertest@7.3.1, vitest@5.0.4, typescript@7.0.2, on node@v22.23.2.
The architecture, and why
Browser Server | | |-- POST /api/auth/login -------->| verify Argon2id hash |<-- Set-Cookie: sid=... ---------| create session row in SQLite | (HttpOnly; SameSite=Lax) | session data stays server-side | | |-- GET /api/auth/me -------------| look up session by signed id | Cookie: sid=... | load userId from the store |<-- 200 {user} ----------------- | Three decisions matter here.
The session lives on the server. The cookie carries only a signed, random session ID. The user's identity is stored in a database row, not in the browser. Logging out actually revokes access, because the server can delete the row. A stateless JWT can't be revoked without extra machinery.
The cookie is HttpOnly. document.cookie can't read it, so an XSS bug can't exfiltrate the session. MDN is explicit that HttpOnly doesn't stop the browser from sending the cookie, which is exactly why the next point matters.
Cookies are sent automatically, so the API needs CSRF protection. If the browser attaches your session cookie to a request from evil.example, then "the request carries a valid session" is not proof the user intended it. A CSRF token that a cross-site attacker can't read fixes that.
The threat model, briefly
| Threat | Mitigation in this tutorial |
|---|---|
| Database leak exposes passwords | Argon2id + unique salt per password |
| XSS steals the session | HttpOnly cookie; session data server-side |
| Session hijacking in transit | Secure cookie in production, HTTPS |
| Cross-site request forgery | Synchronizer token in a custom header |
| Session fixation | Regenerate the session ID on login |
| User enumeration via login timing | Verify against a dummy hash when the email is unknown |
| User enumeration via registration | Not solved here - a duplicate email still returns 409; see the limitations section |
| Brute force | Not solved here - see the limitations section |
1. Project setup
mkdir secure-auth-demo && cd secure-auth-demo npm init -y npm pkg set type=module npm install express express-session argon2 better-sqlite3 zod helmet csrf-sync npm install -D typescript tsx vitest supertest @types/express @types/express-session \ @types/better-sqlite3 @types/supertest @types/node@^22 Two dependency notes worth recording:
-
csurfis deprecated. Express's own blog announced the retirement of legacy packages includingcsurfand points developers at a strategy that fits their architecture. For session-based apps,csrf-syncimplements the OWASP synchronizer token pattern directly. Don't copy older tutorials that still installcsurf. -
sqlite3is no longer maintained. Its upstream repository,TryGhost/node-sqlite3, was archived on 1 July 2026 and is read-only, so no further releases are coming. (The final npm release,6.0.1, is not itself flagged deprecated; the archival is the reason to avoid it.)better-sqlite3is the actively maintained alternative, and its synchronous API also removes a layer of callback plumbing.
package.json scripts:
{ "type": "module", "engines": { "node": ">=22" }, "scripts": { "dev": "tsx watch --env-file=.env src/server.ts", "build": "tsc -p tsconfig.build.json", "start": "node --env-file-if-exists=.env dist/server.js", "typecheck": "tsc --noEmit", "test": "vitest run" } } --env-file is built into Node, so you don't need dotenv. The start script uses --env-file-if-exists: without a .env file, the config validator reports the missing SESSION_SECRET (tested on Node v22.23.2 only). With the plain --env-file flag, I ran npm start without a .env file and it failed with node: .env: not found. One caveat I hit while testing: Node's --env-file doesn't override variables that are already set in the environment. The file is a default source, not an override. If your shell exports PORT, that wins.
TypeScript configuration
TypeScript's Node target mapping lists Node 22 with target/lib ES2023 and module: "nodenext". With ESM and NodeNext, relative imports must include the emitted .js extension.
tsconfig.json (used for type-checking everything, including tests):
{ "compilerOptions": { "target": "ES2023", "lib": ["ES2023"], "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node"], "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "noEmit": true }, "include": ["src", "tests", "vitest.config.ts"] } tsconfig.build.json narrows the build to src so tests are not shipped:
{ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, "rootDir": "src", "outDir": "dist", "sourceMap": true }, "include": ["src"] } 2. Environment configuration
src/config.ts validates the environment once at boot:
import { z } from "zod"; /** * The whole app reads configuration from here, and the process refuses to * start with an invalid configuration instead of failing later at runtime. */ /** * A blank env var (for example `PORT=` in a .env file) should fall back to the * default rather than being coerced into 0 or NaN. */ function optionalNumber(fallback: number) { return z.preprocess( (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), z.coerce.number().int().positive().default(fallback), ); } const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: optionalNumber(3000), // The secret used to sign the session cookie. 32+ bytes of randomness. SESSION_SECRET: z .string() .min(32, "SESSION_SECRET must be at least 32 characters of randomness"), DATABASE_PATH: z.string().min(1).default("./data/app.db"), // How long a session stays valid, in milliseconds. Default: 8 hours. SESSION_TTL_MS: optionalNumber(1000 * 60 * 60 * 8), }); const parsed = EnvSchema.safeParse(process.env); if (!parsed.success) { const details = parsed.error.issues .map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("\n"); throw new Error(`Invalid environment configuration:\n${details}`); } export const config = parsed.data; export const isProduction = config.NODE_ENV === "production"; export const isTest = config.NODE_ENV === "test"; .env.example:
NODE_ENV=development PORT=3000 # Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" SESSION_SECRET= DATABASE_PATH=./data/app.db SESSION_TTL_MS=28800000 Generate a real secret instead of typing one:
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" The min(32) check in the config is only a length check. It can't tell a random string from a guessable one. express-session signs the session ID with HMAC-256, and its README says the secret should contain at least 32 bytes of entropy, which is what the generated value gives you. An earlier version of .env.example had a placeholder here. I copied it as-is and the app booted with it, because the 45-character placeholder passed the 32-character check. That's why the value is blank now: the app refuses to start until you generate a real secret. Add .env, data/, and dist/ to .gitignore.
3. Database and users
src/db.ts opens SQLite and creates the schema:
import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import Database from "better-sqlite3"; import { config } from "./config.js"; // ":memory:" is used by the test suite; a file path is used everywhere else. if (config.DATABASE_PATH !== ":memory:") { mkdirSync(dirname(config.DATABASE_PATH), { recursive: true }); } export const db = new Database(config.DATABASE_PATH); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); export function migrate(): void { db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at); `); } migrate(); src/users.ts is the data access layer. Emails are normalized before they reach it, so a plain UNIQUE column is enough:
import { db } from "./db.js"; export interface User { id: number; email: string; passwordHash: string; createdAt: number; } interface UserRow { id: number; email: string; password_hash: string; created_at: number; } function toUser(row: UserRow): User { return { id: row.id, email: row.email, passwordHash: row.password_hash, createdAt: row.created_at, }; } export function findUserByEmail(email: string): User | undefined { const row = db .prepare("SELECT id, email, password_hash, created_at FROM users WHERE email = ?") .get(email) as UserRow | undefined; return row ? toUser(row) : undefined; } export function findUserById(id: number): User | undefined { const row = db .prepare("SELECT id, email, password_hash, created_at FROM users WHERE id = ?") .get(id) as UserRow | undefined; return row ? toUser(row) : undefined; } export function insertUser(email: string, passwordHash: string): User { const row = db .prepare( `INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?) RETURNING id, email, password_hash, created_at`, ) .get(email, passwordHash, Date.now()) as UserRow; return toUser(row); } Every query uses bound parameters (?), so a crafted email can't inject SQL.
4. Password hashing
OWASP's Password Storage Cheat Sheet recommends Argon2id first, and lists one of its minimum configurations: 19 MiB of memory, 2 iterations, parallelism 1 (m=19456, t=2, p=1).
import argon2 from "argon2"; /** * One of the minimum Argon2id configurations listed in the OWASP Password Storage Cheat Sheet: m=19456 (19 MiB), t=2, p=1. */ const ARGON2_OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1, } as const; export function hashPassword(password: string): Promise<string> { return argon2.hash(password, ARGON2_OPTIONS); } export async function verifyPassword(hash: string, password: string): Promise<boolean> { try { // The encoded hash carries its own algorithm and parameters, so no options // are needed here. return await argon2.verify(hash, password); } catch { return false; } } argon2.verify takes no algorithm option, because the encoded hash string already records the algorithm, memory cost, iterations, and salt, which is why a single password_hash column is enough. verifyPassword returns false on a malformed hash rather than throwing, so a corrupted row can't produce a 500.
Why not bcrypt? OWASP lists bcrypt as an acceptable choice for legacy systems, with a work factor of at least 10, and calls out its 72-byte input limit: passwords longer than that are silently truncated in some implementations. Argon2id is the better default for new code.
5. A SQLite-backed session store
express-session's default MemoryStore is explicitly not for production: it leaks memory and can't be shared across processes. Since SQLite is already in the project, a small store is the cheapest correct option.
express-session calls touch only if the store has one. This setup depends on it, because rolling: true uses it to push the expiry forward. A maintained package, better-sqlite3-session-store, is listed in the express-session README. I wrote a small one here so you can see what a store has to do.
import session from "express-session"; import type { SessionData } from "express-session"; import { config } from "./config.js"; import { db } from "./db.js"; interface SessionRow { data: string; expires_at: number; } export class SqliteSessionStore extends session.Store { override get(sid: string, callback: (err: unknown, session?: SessionData | null) => void): void { try { const row = db .prepare("SELECT data, expires_at FROM sessions WHERE sid = ?") .get(sid) as SessionRow | undefined; if (!row) { callback(null, null); return; } if (row.expires_at <= Date.now()) { // Expired sessions are deleted lazily, on the next read. this.destroy(sid, () => callback(null, null)); return; } callback(null, JSON.parse(row.data) as SessionData); } catch (error) { callback(error); } } override set(sid: string, sessionData: SessionData, callback?: (err?: unknown) => void): void { try { const expiresAt = this.expiresAtFor(sessionData); db.prepare( `INSERT INTO sessions (sid, data, expires_at) VALUES (?, ?, ?) ON CONFLICT (sid) DO UPDATE SET data = excluded.data, expires_at = excluded.expires_at`, ).run(sid, JSON.stringify(sessionData), expiresAt); callback?.(); } catch (error) { callback?.(error); } } override destroy(sid: string, callback?: (err?: unknown) => void): void { try { db.prepare("DELETE FROM sessions WHERE sid = ?").run(sid); callback?.(); } catch (error) { callback?.(error); } } override touch(sid: string, sessionData: SessionData, callback?: (err?: unknown) => void): void { try { db.prepare("UPDATE sessions SET expires_at = ? WHERE sid = ?").run( this.expiresAtFor(sessionData), sid, ); callback?.(); } catch (error) { callback?.(error); } } /** Removes every expired row. Useful as a periodic cleanup job. */ pruneExpired(): number { return db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(Date.now()).changes; } private expiresAtFor(sessionData: SessionData): number { const { expires, maxAge } = sessionData.cookie ?? {}; if (expires) { return new Date(expires).getTime(); } if (typeof maxAge === "number") { return Date.now() + maxAge; } return Date...