DEV Community

Cover image for Session-Based Authentication in Node.js and TypeScript with HttpOnly Cookies and CSRF Protection
Loknath Patel
Loknath Patel

Posted on AI-assisted

Session-Based Authentication in Node.js and TypeScript with HttpOnly Cookies and CSRF Protection

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-session with a custom SQLite store
  • csrf-sync for the synchronizer token pattern
  • Zod for input validation
  • Vitest and supertest for 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} ----------------- |
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two dependency notes worth recording:

  • csurf is deprecated. Express's own blog announced the retirement of legacy packages including csurf and points developers at a strategy that fits their architecture. For session-based apps, csrf-sync implements the OWASP synchronizer token pattern directly. Don't copy older tutorials that still install csurf.
  • sqlite3 is 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-sqlite3 is 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

--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"]
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

Generate a real secret instead of typing one:

node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.now() + config.SESSION_TTL_MS;
  }
}
Enter fullscreen mode Exit fullscreen mode

Expiry is enforced in two places: the store refuses to return an expired row, and pruneExpired() sweeps the table. Expired sessions are deleted on read, so a stale cookie is treated as signed out.

6. Session middleware and secure cookies

import type { Request } from "express";
import session from "express-session";
import { config, isProduction } from "./config.js";
import { SqliteSessionStore } from "./session-store.js";

/**
 * OWASP recommends the `__Host-` prefix for session cookies: it forces the
 * browser to require Secure + Path=/ and to reject any Domain attribute, which
 * makes cookie tossing harder. Browsers refuse `__Host-` cookies over plain
 * HTTP, so we only use it in production where TLS is mandatory.
 */
export const SESSION_COOKIE_NAME = isProduction ? "__Host-sid" : "sid";

export const sessionStore = new SqliteSessionStore();

type RequestHandler = ReturnType<typeof session>;

export function createSessionMiddleware(): RequestHandler {
  return session({
    name: SESSION_COOKIE_NAME,
    secret: config.SESSION_SECRET,
    store: sessionStore,

    // Do not re-save untouched sessions. Fetching a CSRF token modifies the
    // session, so anonymous visitors who call /api/csrf-token do get a session row.
    resave: false,
    saveUninitialized: false,

    // Slide the expiry forward on activity, so active users are not logged out
    // mid-session while idle sessions still expire.
    rolling: true,

    cookie: {
      httpOnly: true,     // not readable from JavaScript
      secure: isProduction, // HTTPS only in production
      sameSite: "lax",    // blocks cross-site POSTs; see the CSRF section
      path: "/",
      maxAge: config.SESSION_TTL_MS,
    },
  });
}

export function regenerateSession(req: Request): Promise<void> {
  return new Promise((resolve, reject) => {
    req.session.regenerate((err) => (err ? reject(err) : resolve()));
  });
}

export function saveSession(req: Request): Promise<void> {
  return new Promise((resolve, reject) => {
    req.session.save((err) => (err ? reject(err) : resolve()));
  });
}

export function destroySession(req: Request): Promise<void> {
  return new Promise((resolve, reject) => {
    req.session.destroy((err) => (err ? reject(err) : resolve()));
  });
}
Enter fullscreen mode Exit fullscreen mode

Every cookie attribute here matters:

  • httpOnly: true. The browser won't expose this cookie to JavaScript. MDN defines it exactly that way. This is the single most important attribute for session cookies.
  • secure: isProduction. The cookie is only sent over HTTPS (browsers make an exception for localhost).
  • sameSite: "lax". The cookie isn't attached to cross-site fetch calls or cross-site POSTs, which removes the simplest CSRF vectors. MDN documents Lax as allowing same-site requests plus cross-site top-level navigations using safe methods. Strict is stronger but hurts usability: clicking a link into your app from another site would arrive logged out.
  • No domain attribute. Omitting it makes this a host-only cookie. OWASP recommends this, and it is required for the __Host- prefix.
  • maxAge. A bounded lifetime instead of a session cookie that lives until the browser closes.

The three helper functions wrap the callback-based express-session API in promises so the route handlers stay readable. They are also why the tests can assert on await.

Session fixation

An attacker who can force a victim to use a session ID the attacker already knows can take over the session. Regenerating the ID on every privilege change (registration, login, and role change) makes that useless. regenerateSession(req) does that, and it is called on both login and registration.

One consequence you must handle in the client: the CSRF token lives in the session, so it is discarded by regeneration. After logging in, fetch /api/csrf-token again. This surprises people; it's not a bug.

7. CSRF protection

OWASP recommends the synchronizer token pattern for stateful apps: the server generates a secret token tied to the session and validates a submitted copy on unsafe methods. csrf-sync implements exactly that, storing the token in req.session.csrfToken.

import { csrfSync } from "csrf-sync";

/**
 * csrf-sync implements the OWASP "synchronizer token pattern" for stateful
 * (session-based) apps.
 *
 * Defaults we rely on, and why:
 *  - The token is stored server-side in `req.session.csrfToken`.
 *  - Unsafe methods (everything except GET/HEAD/OPTIONS) must present the token
 *    in the `x-csrf-token` request header.
 *  - Invalid or missing tokens produce an error with code `EBADCSRFTOKEN`,
 *    which the error handler turns into a 403.
 */
export const { csrfSynchronisedProtection, generateToken } = csrfSync();
Enter fullscreen mode Exit fullscreen mode

I checked the installed source while writing this: csrf-sync ignores GET, HEAD, and OPTIONS by default, reads the token from the x-csrf-token header, generates 128 random bytes (a 256-character hex token), and raises an error with code: "EBADCSRFTOKEN".

The token must not live in a cookie, and it must not be sent in a URL, because either would defeat the point. A custom header works because reading a custom header requires CORS permission the attacker doesn't have.

8. Routes

import { Router } from "express";
import { z } from "zod";
import { isProduction } from "../config.js";
import { hashPassword, verifyPassword } from "../password.js";
import { requireAuth, currentUserId } from "../middleware/require-auth.js";
import {
  SESSION_COOKIE_NAME,
  destroySession,
  regenerateSession,
  saveSession,
} from "../session.js";
import { findUserByEmail, findUserById, insertUser } from "../users.js";

export const authRouter = Router();

/** Normalise the email before validating it, so case never creates duplicates. */
const emailSchema = z.string().trim().toLowerCase().pipe(z.email());

const registerSchema = z.object({
  email: emailSchema,
  // Length matters more than character-class rules. NIST SP 800-63B recommends
  // allowing long passphrases; we require at least 12 characters and cap the
  // length so a huge body cannot be used to burn CPU.
  password: z.string().min(12).max(200),
});

// Login must not re-apply the registration policy: doing so would leak the
// policy and lock out accounts created before a policy change.
const loginSchema = z.object({
  email: emailSchema,
  password: z.string().min(1).max(200),
});

interface PublicUser {
  id: number;
  email: string;
}

function toPublicUser(user: { id: number; email: string }): PublicUser {
  return { id: user.id, email: user.email };
}

function isUniqueViolation(error: unknown): boolean {
  return (
    typeof error === "object" &&
    error !== null &&
    "code" in error &&
    (error as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE"
  );
}

/**
 * A throwaway hash used when the submitted email does not exist. A failed login
 * then performs one real Argon2id verification whether or not the account
 * exists, which narrows the timing gap. It does not remove it: the database
 * lookup and session handling still differ, so treat this as best-effort.
 */
let dummyHash: Promise<string> | undefined;
function getDummyHash(): Promise<string> {
  dummyHash ??= hashPassword("timing-equalisation-placeholder-value");
  return dummyHash;
}
void getDummyHash(); // warm up at startup so the first unknown-email login isn't slower

authRouter.post("/register", async (req, res) => {
  const parsed = registerSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({
      error: "Invalid registration details",
      issues: parsed.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
    return;
  }

  const { email, password } = parsed.data;

  if (findUserByEmail(email)) {
    res.status(409).json({ error: "Email is already registered" });
    return;
  }

  const passwordHash = await hashPassword(password);

  let user;
  try {
    user = insertUser(email, passwordHash);
  } catch (error) {
    // Two requests can pass the check above at the same time; the UNIQUE
    // constraint is the real guarantee, so translate it into the same 409.
    if (isUniqueViolation(error)) {
      res.status(409).json({ error: "Email is already registered" });
      return;
    }
    throw error;
  }

  // Never keep the pre-login session id after a privilege change.
  await regenerateSession(req);
  req.session.userId = user.id;
  await saveSession(req);

  res.status(201).json({ user: toPublicUser(user) });
});

authRouter.post("/login", async (req, res) => {
  const parsed = loginSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Invalid login payload" });
    return;
  }

  const { email, password } = parsed.data;
  const user = findUserByEmail(email);

  const hash = user?.passwordHash ?? (await getDummyHash());
  const passwordMatches = await verifyPassword(hash, password);

  if (!user || !passwordMatches) {
    // Identical response for "no such user" and "wrong password" so the API
    // does not confirm which emails exist.
    res.status(401).json({ error: "Invalid email or password" });
    return;
  }

  await regenerateSession(req);
  req.session.userId = user.id;
  await saveSession(req);

  res.json({ user: toPublicUser(user) });
});

authRouter.get("/me", requireAuth, (req, res) => {
  const userId = currentUserId(req);

  const user = userId === undefined ? undefined : findUserById(userId);
  if (!user) {
    res.status(401).json({ error: "Authentication required" });
    return;
  }

  res.json({ user: toPublicUser(user) });
});

authRouter.post("/logout", async (req, res) => {
  await destroySession(req);
  res.clearCookie(SESSION_COOKIE_NAME, {
    httpOnly: true,
    secure: isProduction,
    sameSite: "lax",
    path: "/",
  });
  res.status(204).end();
});
Enter fullscreen mode Exit fullscreen mode

A few notes on that code:

  • toPublicUser never includes passwordHash. Don't serialize database rows straight to JSON. An explicit projection is the difference between a working API and a hash-leak.
  • Normalize, then validate. z.string().trim().toLowerCase().pipe(z.email()) means " Alice@Example.COM " and "alice@example.com" are the same account.
  • The dummy-hash trick. When the email is unknown, the code still runs a real Argon2id verification against a throwaway hash, so a failed login does the same expensive work whether or not the account exists. Without it, a request naming a non-existent email would skip hashing entirely and return noticeably faster, a timing side channel that can disclose which emails are registered. This narrows the gap; it doesn't make the two paths identical, because the database lookup and session handling still differ. I saw the lazy version of this: my first unknown-email login after server start took 0.0457 s against about 0.024 s afterwards, because the dummy hash was created lazily.
  • One error message for both failures. "Invalid email or password" for both cases avoids a second enumeration channel.
  • The database constraint is the real guard. The pre-check is for a friendly 409; the UNIQUE constraint is what actually prevents two accounts in a race. Both paths return the same response.

SP 800-63B-4 sets 15 characters as the minimum for single-factor passwords; this demo uses 12 to balance security with user friction.

src/middleware/require-auth.ts:

import type { NextFunction, Request, Response } from "express";

/** The id of the authenticated user, or `undefined` if not signed in. */
export function currentUserId(req: Request): number | undefined {
  const { userId } = req.session;
  return typeof userId === "number" ? userId : undefined;
}

export function requireAuth(req: Request, res: Response, next: NextFunction): void {
  if (currentUserId(req) === undefined) {
    res.status(401).json({ error: "Authentication required" });
    return;
  }
  next();
}
Enter fullscreen mode Exit fullscreen mode

And the session type augmentation, src/types/express-session.d.ts:

import "express-session";

declare module "express-session" {
  interface SessionData {
    /** The authenticated user's id. Absent for anonymous visitors. */
    userId?: number;
  }
}
Enter fullscreen mode Exit fullscreen mode

9. App wiring: order is the specification

import express, { type Express } from "express";
import helmet from "helmet";
import { isProduction } from "./config.js";
import { csrfSynchronisedProtection, generateToken } from "./middleware/csrf.js";
import { errorHandler, notFoundHandler } from "./middleware/error-handler.js";
import { authRouter } from "./routes/auth.js";
import { createSessionMiddleware } from "./session.js";

export function createApp(): Express {
  const app = express();

  // Behind a TLS-terminating proxy, trust the first hop so express-session sees
  // X-Forwarded-Proto and will set `Secure` cookies correctly.
  if (isProduction) {
    app.set("trust proxy", 1);
  }

  app.disable("x-powered-by");

  app.use(helmet());
  app.use(express.json({ limit: "16kb" }));
  app.use(createSessionMiddleware());

  app.get("/api/health", (_req, res) => {
    res.json({ status: "ok" });
  });

  // Hand the client a CSRF token bound to its session. GET is a safe method, so
  // this route is exempt from CSRF checks.
  app.get("/api/csrf-token", (req, res) => {
    res.json({ csrfToken: generateToken(req) });
  });

  app.use(csrfSynchronisedProtection);
  app.use("/api/auth", authRouter);

  app.use(notFoundHandler);
  app.use(errorHandler);

  return app;
}
Enter fullscreen mode Exit fullscreen mode

Order matters:

  1. helmet() sets security headers on every response, including errors, so it goes first.
  2. express.json({ limit: "16kb" }) must precede anything reading req.body, and the size limit stops a giant body from burning CPU in Argon2. Requests over the limit are rejected with 413 before any handler runs.
  3. The session middleware must precede CSRF and routes, because both need req.session.
  4. /api/csrf-token must be registered before csrfSynchronisedProtection. Register it after, and clients can never obtain a token for their first mutation. It's an easy mistake to make.
  5. CSRF protection guards every unsafe request below it.
  6. notFoundHandler and errorHandler come last.

A note if you are new to Express 5: it automatically forwards rejected promises from async handlers to the error middleware, so the route handlers above don't need try/catch around every await. That was a manual next(err) in Express 4.

src/middleware/error-handler.ts:

import type { NextFunction, Request, Response } from "express";

interface HttpErrorLike {
  code?: string;
  type?: string;
  status?: number;
  statusCode?: number;
}

export function notFoundHandler(_req: Request, res: Response): void {
  res.status(404).json({ error: "Not found" });
}

/**
 * Central error handler. It must keep all four parameters — Express detects
 * error-handling middleware by arity.
 */
export function errorHandler(
  err: unknown,
  _req: Request,
  res: Response,
  next: NextFunction,
): void {
  if (res.headersSent) {
    next(err);
    return;
  }

  const httpError = err as HttpErrorLike;

  if (httpError?.code === "EBADCSRFTOKEN") {
    res.status(403).json({ error: "Invalid or missing CSRF token" });
    return;
  }

  // body-parser marks malformed JSON with type: "entity.parse.failed".
  if (httpError?.type === "entity.parse.failed") {
    res.status(400).json({ error: "Malformed JSON body" });
    return;
  }

  // Other body-parser failures carry their own 4xx status (for example
  // "entity.too.large" -> 413 from the 16kb limit, and "encoding.unsupported"
  // -> 415). Honour it instead of reporting a server fault for a client error.
  const status = httpError?.status ?? httpError?.statusCode;
  if (typeof status === "number" && status >= 400 && status < 500) {
    res.status(status).json({
      error: httpError?.type === "entity.too.large" ? "Request body too large" : "Bad request",
    });
    return;
  }

  // Anything else is unexpected: log it server-side and return a generic
  // message so stack traces and SQL errors never reach the client.
  console.error("Unhandled error:", err);
  res.status(500).json({ error: "Internal server error" });
}
Enter fullscreen mode Exit fullscreen mode

The handler maps each failure to a status: EBADCSRFTOKEN to 403, a malformed JSON body to 400, and other body-parser errors to the 4xx status they already carry, for example entity.too.large to 413 when a request exceeds the 16kb limit. Without that last branch an oversized body falls through to the generic case and is reported as a 500 server fault, which misleads the client and buries a client error in your server logs.

The error handler must keep all four parameters, because Express identifies error middleware by arity. Dropping the unused next silently turns it into ordinary middleware that never runs.

10. Entry point

import { createApp } from "./app.js";
import { config } from "./config.js";
import { sessionStore } from "./session.js";

const app = createApp();

const server = app.listen(config.PORT, () => {
  console.log(`Auth API listening on http://localhost:${config.PORT} (${config.NODE_ENV})`);
});

const PRUNE_INTERVAL_MS = 30 * 60 * 1000;
const pruneTimer = setInterval(() => {
  const removed = sessionStore.pruneExpired();
  if (removed > 0) {
    console.log(`Pruned ${removed} expired session(s)`);
  }
}, PRUNE_INTERVAL_MS);
pruneTimer.unref();

function shutdown(signal: NodeJS.Signals): void {
  console.log(`${signal} received, shutting down`);
  clearInterval(pruneTimer);
  server.close(() => {
    process.exit(0);
  });
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
Enter fullscreen mode Exit fullscreen mode

11. Tests

vitest.config.ts injects a deterministic environment, including an in-memory database so each run starts clean:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    env: {
      NODE_ENV: "test",
      PORT: "3000",
      // 32+ characters so it passes config validation.
      SESSION_SECRET: "test-only-secret-value-please-change-me-0001",
      // In-memory SQLite: each test file gets its own throwaway database.
      DATABASE_PATH: ":memory:",
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

The integration tests drive the app through supertest with a cookie-persisting agent. The suite has 6 cases, and this is the whole file:

import request from "supertest";
import { describe, expect, it } from "vitest";
import { createApp } from "../src/app.js";
import { db } from "../src/db.js";

const app = createApp();
const VALID_PASSWORD = "correct horse battery staple";

type Agent = ReturnType<typeof request.agent>;

function setCookieHeaders(res: request.Response): string[] {
  const raw = res.headers["set-cookie"];
  if (!raw) return [];
  return Array.isArray(raw) ? raw : [raw];
}

function sessionCookie(res: request.Response): string {
  const cookie = setCookieHeaders(res).find((value) => value.startsWith("sid="));
  if (!cookie) throw new Error("Response did not set a session cookie");
  return cookie;
}

async function getCsrfToken(agent: Agent): Promise<string> {
  const res = await agent.get("/api/csrf-token");
  expect(res.status).toBe(200);
  return res.body.csrfToken as string;
}

async function register(agent: Agent, email: string, password = VALID_PASSWORD) {
  const token = await getCsrfToken(agent);
  return agent.post("/api/auth/register").set("x-csrf-token", token).send({ email, password });
}

describe("auth api", () => {
  it("sets an HttpOnly, SameSite=Lax session cookie", async () => {
    const agent = request.agent(app);
    const res = await register(agent, "cookie@example.com");
    const cookie = sessionCookie(res);

    expect(cookie).toMatch(/HttpOnly/i);
    expect(cookie).toMatch(/SameSite=Lax/i);
    expect(cookie).not.toMatch(/;\s*Secure/i); // dev/test, so it also works over HTTP
  });

  it("rejects a duplicate email with 409", async () => {
    const first = request.agent(app);
    expect((await register(first, "dup@example.com")).status).toBe(201);

    const second = request.agent(app);
    const res = await register(second, "dup@example.com");
    expect(res.status).toBe(409);
  });

  it("rotates the session id on login to prevent session fixation", async () => {
    const agent = request.agent(app);
    const registered = await register(agent, "rotate@example.com");
    const before = sessionCookie(registered);

    const token = await getCsrfToken(agent);
    const loggedIn = await agent
      .post("/api/auth/login")
      .set("x-csrf-token", token)
      .send({ email: "rotate@example.com", password: VALID_PASSWORD });

    expect(loggedIn.status).toBe(200);
    expect(sessionCookie(loggedIn)).not.toBe(before);
  });

  it("rejects an unknown email with the same message as a wrong password", async () => {
    const agent = request.agent(app);
    const token = await getCsrfToken(agent);
    const res = await agent
      .post("/api/auth/login")
      .set("x-csrf-token", token)
      .send({ email: "nobody@example.com", password: VALID_PASSWORD });

    expect(res.status).toBe(401);
    expect(res.body.error).toBe("Invalid email or password");
  });

  it("blocks a state-changing request that has no token", async () => {
    const res = await request(app)
      .post("/api/auth/register")
      .send({ email: "no-csrf@example.com", password: VALID_PASSWORD });

    expect(res.status).toBe(403);
    expect(res.body.error).toMatch(/CSRF/i);
  });

  it("invalidates the session on logout", async () => {
    const agent = request.agent(app);
    await register(agent, "logout@example.com");
    expect((await agent.get("/api/auth/me")).status).toBe(200);

    const token = await getCsrfToken(agent);
    expect((await agent.post("/api/auth/logout").set("x-csrf-token", token)).status).toBe(204);
    expect((await agent.get("/api/auth/me")).status).toBe(401);
  });
});
Enter fullscreen mode Exit fullscreen mode

My first version of the fourth test failed with TypeError: Cannot read properties of null (reading 'address') from supertest, because I awaited the CSRF token inside the request chain. Fetching the token into a variable first, as the test above does, fixes it.

Run it:

npm test
Enter fullscreen mode Exit fullscreen mode

Expected result on this codebase:

 Test Files  1 passed (1)
      Tests  6 passed (6)
   Duration  808ms (import 45%, tests 43%, transform 11%, worker 1%)
Enter fullscreen mode Exit fullscreen mode

Durations differ between machines; the counts are what matter. Also run npm run typecheck (which must exit 0) and npm run build before deploying anywhere; build compiles src to dist/ via tsconfig.build.json.

12. Trying it by hand

With npm run dev running, this curl sequence exercises the whole flow. The cookie jar is what makes the session persist across calls.

JAR=$(mktemp); B=http://localhost:3000

# 1. Grab a CSRF token; the cookie jar now holds the session cookie.
TOKEN=$(curl -s -c "$JAR" $B/api/csrf-token | sed -E 's/.*"csrfToken":"([^"]+)".*/\1/')

# 2. Register (201)
curl -s -b "$JAR" -c "$JAR" -X POST $B/api/auth/register \
  -H 'Content-Type: application/json' -H "x-csrf-token: $TOKEN" \
  -d '{"email":"me@example.com","password":"correct horse battery staple"}'

# 3. Get a fresh token (the session was rotated) and read the profile (200)
TOKEN=$(curl -s -b "$JAR" -c "$JAR" $B/api/csrf-token | sed -E 's/.*"csrfToken":"([^"]+)".*/\1/')
curl -s -b "$JAR" $B/api/auth/me

# 4. Logout (204), then confirm access is gone (401)
curl -s -o /dev/null -w '%{http_code}\n' -b "$JAR" -c "$JAR" -X POST $B/api/auth/logout -H "x-csrf-token: $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' -b "$JAR" $B/api/auth/me
Enter fullscreen mode Exit fullscreen mode

Output from this project, including the Set-Cookie header (session ID shortened):

Set-Cookie: sid=s%3A<id>.<signature>; Path=/; Expires=Wed, 14 Oct 2026 19:53:24 GMT; HttpOnly; SameSite=Lax
Enter fullscreen mode Exit fullscreen mode

Note there is no Secure flag because NODE_ENV=development. Set NODE_ENV=production, terminate TLS, and the cookie becomes __Host-sid=...; Secure; HttpOnly; SameSite=Lax.

Common mistakes and troubleshooting

"403 on every POST, even with a token." Three usual causes. The token endpoint is registered after csrfSynchronisedProtection. Or the client is reading csrfToken from the wrong place. Or the client is sending the token as a cookie or a query parameter instead of the x-csrf-token header.

"Login succeeds but the very next mutation fails with 403." That's expected. Login regenerates the session, which discards the CSRF token. Fetch a new token after authenticating.

"The cookie is set but never sent back." Check, in order: secure: true over plain HTTP (the browser drops it, with a localhost exception); sameSite: "strict" breaking a top-level navigation from another origin; a domain mismatch; or a cross-origin client that is not sending credentials. For fetch calls, credentials: "include" is required, along with explicit CORS Access-Control-Allow-Credentials: true and a specific origin, because * is not allowed with credentials.

"req.session is undefined in a route." The session middleware is registered after the route, or after CSRF. Both need it, so it must come first.

"Users get logged out randomly." resave and saveUninitialized are usually to blame, or the store is not saving. With saveUninitialized: false, a session is only created when it is actually modified, which is correct for login flows.

"Argon2 install fails." argon2 and better-sqlite3 are native modules. Most platforms get prebuilt binaries; if not, the resolution is a working build toolchain, not a switch to a pure-JS hash.

"JSON.parse throws inside the session store." If you changed the stored shape, clear the sessions table. A schema change to session data effectively logs everyone out.

"SESSION_SECRET must be at least 32 characters" on boot. That message comes from the config validator. Generate a value rather than inventing one. It also fires if SESSION_SECRET is left blank in your .env.

What this tutorial does not cover

This is not a finished product. Deliberately out of scope:

  • Registration discloses whether an email is registered. POST /api/auth/register answers 409 for an email that already exists, so anyone can probe the endpoint for accounts. Login is protected against this; registration is not, because the answer the caller wants is also the answer that makes the API useful. Hiding it requires moving account creation behind an email-verification step.
  • Rate limiting, lockout, and hashing cost. Nothing here slows down credential stuffing. Add per-IP and per-account throttling (for example express-rate-limit) and alert on repeated failures. Argon2id is expensive by design, so a burst of login attempts can exhaust worker capacity. The 16kb body limit and the 200-character password cap bound one request's input; they don't bound the request rate, which is the rate limiter's job.
  • Email verification, password reset, and MFA. Email verification and password reset both need a token with its own single-use, expiring lifecycle, and MFA should be added before anything else.
  • Anonymous session rows. Every GET /api/csrf-token without a cookie creates a row, and nothing limits how many. Three cookie-less calls added three rows to my sessions table. My first draft's comment claimed anonymous visitors don't get a session.
  • No absolute session lifetime. rolling: true slides the expiry forward on every authenticated request, so an active session never expires on its own.
  • No breached-password check. SP 800-63B-4 says passwords SHALL be compared against a blocklist; this code doesn't.
  • Secret rotation, audit logging, and observability. OWASP's session guidance covers renewing session IDs and detecting session-related attacks; this code does neither. There are no metrics, no tracing, and no structured logging.
  • Scaling. SQLite is a single-writer database. It is a fine fit for one process or one machine, and it is the wrong fit for horizontal scaling. The store interface is the seam: swap in Redis or Postgres.
  • __Host- prefix in development. It requires HTTPS, which is why it is production-only here. If you add local TLS, use it everywhere.

One more thing: disabling CSRF protection because "it is only an API" is not a mitigation. If your API is called from a browser with a cookie, it is a CSRF target.

Conclusion

The difference between "we added login" and "we added authentication" is a short list of decisions: hash with Argon2id at OWASP's parameters, keep session state on the server, mark the cookie HttpOnly and Secure, add SameSite, protect unsafe methods with a synchronizer token, regenerate the session ID on login, and make login fail the same way for an unknown email and a wrong password so it doesn't leak which accounts exist (registration still does, see the limitations).

The complete, runnable project, including the SQLite session store and all 6 tests, is at secure-auth-demo. npm install && npm test should print 6 passed.

References

Top comments (0)