DEV Community

Mohd Amir
Mohd Amir

Posted on AI-assisted

MERN + TypeScript Code Cheat Sheet — Part 1 (Setup, Express, Mongoose, Aggregation, Auth)

MERN Cheat Sheet — Part 1

Snippets come from express-mongo-auth-starter and react-vite-auth-starter, cleaned up. (verify) = not in my repos and I'm not 100% sure of the API. Snippets are TypeScript unless noted.

Versions assumed (from package.json)

Backend Version Frontend Version
node >=20 react / react-dom ^19.2.8
express ^5.1.0 vite ^8.3.0
mongoose ^8.18.1 typescript ~6.0.2
zod ^4.1.5 zod ^4.6.5
jsonwebtoken ^9.0.2 axios ^1.20.0
bcryptjs ^3.0.2 @tanstack/react-query ^5.104.1
bullmq ^6.3.11 react-router-dom ^7.18.4
ioredis ^6.0.0 react-hook-form ^7.89.0
express-rate-limit ^8.1.0 @hookform/resolvers ^5.9.1
pino / pino-http ^9.9.4 / ^10.5.0 tailwindcss + @tailwindcss/vite ^4.3.3
typescript ^5.9.2 vitest ^5.0.3
tsx ^4.20.5 @testing-library/react ^16.3.3
vitest ^4.1.11 jsdom ^29.1.1
supertest ^7.1.4
mongodb-memory-server ^10.2.3

Not in either package.json (latest stable assumed, (verify)): helmet, cors, puppeteer, mysql2, a MySQL server.

Repo bugs I found while extracting (fixed in snippets)

  • utils/helpers.ts: id regex ends with \$ → never matches. Use /^[\da-f]{24}$/i.
  • review.service.ts: locationId should be location; $ratings should be $rating; lte needs $lte; facet key ratingg must be ratings; list filters use owner/title, which Review doesn't have.
  • review.schemas.ts reply is { owner, text } but the model's reply is { owner, reply }.
  • config/redis.ts: maxRetriesPerRequest: 0 → BullMQ workers need null.
  • config/env.ts: REDIS_PORT has .min(6379) — probably meant a plain port range.

Table of contents

  1. Setup
  2. Express patterns
  3. Mongoose
  4. Aggregation + indexing
  5. Auth

1. Setup

1.1 Install

npm i express mongoose zod dotenv pino pino-http express-rate-limit jsonwebtoken bcryptjs cookie
npm i -D typescript tsx vitest supertest mongodb-memory-server @types/node @types/express @types/jsonwebtoken @types/supertest
Enter fullscreen mode Exit fullscreen mode

Gotcha: "type": "module" + NodeNext means relative imports need the .js extension.

1.2 package.json scripts

{
  "type": "module",
  "engines": { "node": ">=20" },
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  }
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: tsx only runs TS, it doesn't typecheck — keep typecheck in CI.

1.3 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: rootDir: src means tests outside src aren't compiled by tsc (Vitest handles them).

1.4 Env validation with Zod (src/config/env.ts)

import "dotenv/config";
import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  TRUST_PROXY: z.coerce.number().int().min(0).default(0),
  MONGO_URI: z.string().min(1),
  ACCESS_TOKEN_SECRET: z.string().min(32),
  REFRESH_TOKEN_SECRET: z.string().min(32),
  AUTH_COOKIE_SECURE: z.stringbool().default(true),
  AUTH_COOKIE_SAME_SITE: z.enum(["strict", "lax", "none"]).default("lax"),
}).refine((v) => v.ACCESS_TOKEN_SECRET !== v.REFRESH_TOKEN_SECRET, {
  message: "Access and refresh secrets must differ",
});

const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
  console.error(parsed.error.issues.map(({ path, message }) => ({ path, message })));
  process.exit(1);
}
export const env = parsed.data;
Enter fullscreen mode Exit fullscreen mode

Gotcha: use z.stringbool() for flags — z.coerce.boolean() turns "false" into true.

1.5 app.ts

import express from "express";
import { env } from "./config/env.js";
import { notFound } from "./middlewares/not-found.js";
import { errorHandler } from "./middlewares/error-handler.js";
import itemRoutes from "./modules/items/item.routes.js";

export const app = express();
app.disable("x-powered-by");
app.set("trust proxy", env.TRUST_PROXY);
app.use(express.json({ limit: "1mb" }));

app.get("/api/health", (_req, res) => res.json({ success: true, data: { status: "ok" } }));
app.use("/api/items", itemRoutes);

app.use(notFound);
app.use(errorHandler);
Enter fullscreen mode Exit fullscreen mode

Gotcha: app is exported without listen so supertest can import it.

1.6 server.ts + graceful shutdown

import { app } from "./app.js";
import { connectDatabase, disconnectDatabase } from "./config/db.js";
import { env } from "./config/env.js";

let server: ReturnType<typeof app.listen> | undefined;

async function shutdown(signal: string) {
  console.log("shutdown", signal);
  if (server) {
    await new Promise<void>((res, rej) => server!.close((e) => (e ? rej(e) : res())));
  }
  await disconnectDatabase();
}

for (const sig of ["SIGINT", "SIGTERM"] as const) {
  process.once(sig, () => {
    shutdown(sig).then(() => process.exit(0)).catch(() => process.exit(1));
  });
}

connectDatabase()
  .then(() => { server = app.listen(env.PORT); })
  .catch((e) => { console.error(e); process.exit(1); });
Enter fullscreen mode Exit fullscreen mode

Gotcha: server.close waits for keep-alive connections — add a force-exit timer if deploys hang.


2. Express patterns

2.1 asyncHandler

import type { RequestHandler } from "express";

export const asyncHandler = (fn: RequestHandler): RequestHandler =>
  (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
Enter fullscreen mode Exit fullscreen mode

Gotcha: Express 5 already forwards rejected promises from handlers — this wrapper is harmless but optional.

2.2 ApiError

export class ApiError extends Error {
  constructor(
    public readonly statusCode: number,
    message: string,
    public readonly code = "API_ERROR",
  ) {
    super(message);
    this.name = "ApiError";
  }
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: keep code machine-readable; the frontend branches on it, not on message.

2.3 Central error handler

import type { ErrorRequestHandler } from "express";
import { ZodError } from "zod";
import mongoose from "mongoose";
import { ApiError } from "../utils/api-error.js";

const fail = (res: any, status: number, code: string, message: string, details?: unknown) =>
  res.status(status).json({ success: false, error: { code, message, details } });

export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof ZodError)
    return fail(res, 400, "VALIDATION_ERROR", "Validation failed",
      err.issues.map(({ path, message, code }) => ({ path, message, code })));
  if (err instanceof mongoose.Error.CastError) return fail(res, 400, "INVALID_ID", "Invalid id");
  if (err?.code === 11000) return fail(res, 409, "DUPLICATE_KEY", "Already exists");
  if (["JsonWebTokenError", "TokenExpiredError", "NotBeforeError"].includes(err?.name))
    return fail(res, 401, "UNAUTHORIZED", "Invalid or expired token");
  if (err instanceof ApiError) return fail(res, err.statusCode, err.code, err.message);
  console.error(err);
  return fail(res, 500, "INTERNAL_SERVER_ERROR", "Internal server error");
};
Enter fullscreen mode Exit fullscreen mode

Gotcha: it must have 4 args (even if unused) or Express won't treat it as an error handler; mount it last.

2.4 validate middleware

import type { RequestHandler } from "express";
import type { ZodType } from "zod";

export const validateBody = <T>(schema: ZodType<T>): RequestHandler =>
  (req, _res, next) => { req.body = schema.parse(req.body); next(); };

export const validateQuery = <T>(schema: ZodType<T>): RequestHandler =>
  (req, res, next) => { res.locals.query = schema.parse(req.query); next(); };
Enter fullscreen mode Exit fullscreen mode

Gotcha: Express 5 req.query is a getter — don't assign to it; read res.locals.query (my repo parses the query inside the controller instead). validateQuery is my addition, not in the repo.

2.5 404

import type { RequestHandler } from "express";
import { ApiError } from "../utils/api-error.js";

export const notFound: RequestHandler = (req) => {
  throw new ApiError(404, `Route ${req.method} ${req.originalUrl} not found`, "NOT_FOUND");
};
Enter fullscreen mode Exit fullscreen mode

Gotcha: mount after all routes, before errorHandler.

2.6 helmet (not in my repo — verify)

import helmet from "helmet";

app.use(helmet());
Enter fullscreen mode Exit fullscreen mode

Gotcha: for a pure JSON API the defaults are fine; only tune CSP if you serve HTML.

2.7 cors with credentials (not in my repo — verify)

import cors from "cors";

app.use(cors({
  origin: ["http://localhost:5173"],
  credentials: true,
}));
Enter fullscreen mode Exit fullscreen mode

Gotcha: with credentials: true the origin can't be *, and axios needs withCredentials: true. My Vite dev proxy avoids CORS entirely.

2.8 Rate limit

import { ipKeyGenerator, rateLimit } from "express-rate-limit";
import { ApiError } from "../utils/api-error.js";

export const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  skipSuccessfulRequests: true,
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) =>
    `${ipKeyGenerator(req.ip ?? "")}:${String(req.body?.email ?? "").trim().toLowerCase()}`,
  handler: (_req, _res, next) => next(new ApiError(429, "Too many requests", "RATE_LIMITED")),
});
Enter fullscreen mode Exit fullscreen mode

Gotcha: the default store is in-memory per process — use a shared store (e.g. Redis) with multiple instances.

2.9 trust proxy

// TRUST_PROXY=1 when behind exactly one proxy (nginx, a load balancer)
app.set("trust proxy", env.TRUST_PROXY);
Enter fullscreen mode Exit fullscreen mode

Gotcha: wrong hop count = every client looks like the proxy IP (rate limit blocks everyone) or spoofable via X-Forwarded-For.


3. Mongoose

3.1 Connect

import mongoose from "mongoose";
import { env } from "./env.js";

export const connectDatabase = () => mongoose.connect(env.MONGO_URI);
export const disconnectDatabase = () => mongoose.disconnect();
Enter fullscreen mode Exit fullscreen mode

Gotcha: call connect once at startup; Mongoose buffers queries until it's connected.

3.2 Typed schema + model

import { Schema, model, type HydratedDocument, type Model, Types } from "mongoose";

export interface Item {
  owner: Types.ObjectId;
  title: string;
  status: "open" | "done";
  rating?: number;
  createdAt: Date;
  updatedAt: Date;
}
export type ItemDocument = HydratedDocument<Item>;

const itemSchema = new Schema<Item>({
  owner: { type: Schema.Types.ObjectId, ref: "User", required: true },
  title: { type: String, required: true, trim: true, minlength: 1, maxlength: 120 },
  status: { type: String, enum: ["open", "done"], default: "open", required: true },
  rating: { type: Number, min: 1, max: 5, validate: { validator: Number.isInteger, message: "Integer only" } },
}, { timestamps: true });

export const ItemModel: Model<Item> = model<Item>("Item", itemSchema);
Enter fullscreen mode Exit fullscreen mode

Gotcha: min/max/validate only run on create/save; for updates pass runValidators: true.

3.3 Indexes (compound, unique, TTL)

itemSchema.index({ owner: 1, createdAt: -1 });   // compound: owner first, matches every list query
itemSchema.index({ owner: 1, status: 1 });

userSchema.path("email").index({ unique: true }); // or: email: { type: String, unique: true }

sessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // TTL: delete at expiresAt
Enter fullscreen mode Exit fullscreen mode

Gotcha: unique is an index, not a validator — a violation throws error code 11000; TTL deletion runs roughly every 60s.

3.4 Timestamps

new Schema<Item>({ /* ... */ }, { timestamps: true });                          // createdAt + updatedAt
new Schema<Session>({ /* ... */ }, { timestamps: { createdAt: true, updatedAt: false } });
Enter fullscreen mode Exit fullscreen mode

Gotcha: findOneAndUpdate/updateOne bump updatedAt automatically; $set: { updatedAt } manually isn't needed.

3.5 toJSON transform + select: false

const userSchema = new Schema<User>({
  email: { type: String, required: true, unique: true, lowercase: true, trim: true },
  password: { type: String, required: true, select: false },
}, {
  timestamps: true,
  toJSON: { transform: (_doc, ret) => { delete (ret as Partial<typeof ret>).password; return ret; } },
});

await UserModel.findOne({ email }).select("+password"); // opt in only for login
Enter fullscreen mode Exit fullscreen mode

Gotcha: toJSON only runs on res.json(doc) / doc.toJSON(), not on .lean() results.

3.6 Owner-scoped CRUD

const oid = (id: string) => new Types.ObjectId(id);

export const createItem = (userId: string, input: CreateItemInput) =>
  ItemModel.create({ ...input, owner: oid(userId) });

export const getItem = (userId: string, id: string) =>
  ItemModel.findOne({ _id: oid(id), owner: oid(userId) });

export const updateItem = (userId: string, id: string, input: UpdateItemInput) =>
  ItemModel.findOneAndUpdate(
    { _id: oid(id), owner: oid(userId) },
    { $set: input },
    { new: true, runValidators: true },
  );

export const deleteItem = (userId: string, id: string) =>
  ItemModel.findOneAndDelete({ _id: oid(id), owner: oid(userId) });
Enter fullscreen mode Exit fullscreen mode

Gotcha: filter by owner in the query itself, and return 404 (not 403) for someone else's id so ids don't leak.

3.7 Pagination + filters + sort whitelist

const sortFields = {
  createdAt: { createdAt: 1 },
  "-createdAt": { createdAt: -1 },
  rating: { rating: -1 },
} as const;

export async function listItems(userId: string, q: ListItemsQuery) {
  const filter: FilterQuery<Item> = { owner: oid(userId) };
  if (q.status) filter.status = q.status;
  if (q.rating) filter.rating = q.rating;

  const [items, total] = await Promise.all([
    ItemModel.find(filter).sort(sortFields[q.sort]).skip((q.page - 1) * q.limit).limit(q.limit),
    ItemModel.countDocuments(filter),
  ]);
  return { items, page: q.page, limit: q.limit, total, totalPages: Math.ceil(total / q.limit) };
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: sort comes from a Zod z.enum([...]) and is looked up in the map — never pass a raw query string into .sort(). Large skip is slow; use range-based cursors at scale.

3.8 Query schema for the above

const page = z.string().regex(/^\d+$/).optional().transform((v) => Number(v ?? "1")).pipe(z.number().int().min(1));
const limit = z.string().regex(/^\d+$/).optional().transform((v) => Number(v ?? "10")).pipe(z.number().int().min(1).max(50));

export const listItemsQuerySchema = z.strictObject({
  page, limit,
  status: z.enum(["open", "done"]).optional(),
  rating: z.string().regex(/^[1-5]$/).transform(Number).optional(),
  q: z.string().trim().max(120).optional(),
  sort: z.enum(["createdAt", "-createdAt", "rating"]).default("-createdAt"),
});
Enter fullscreen mode Exit fullscreen mode

Gotcha: strictObject rejects unknown keys; query values are always strings, so parse before use.

3.9 Regex-safe search

const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

if (q.q) filter.title = { $regex: escapeRegex(q.q), $options: "i" };
Enter fullscreen mode Exit fullscreen mode

Gotcha: an unescaped user regex is a ReDoS/injection risk; case-insensitive $regex can't use an index well — consider a text index for big collections.

3.10 lean

const items = await ItemModel.find({ owner: oid(userId) }).sort({ createdAt: -1 }).lean();
Enter fullscreen mode Exit fullscreen mode

Gotcha: .lean() returns plain objects — no toJSON transform, virtuals, or .save().

3.11 populate

const reviews = await ReviewModel.find({ location: locationId })
  .populate<{ location: { name: string; city: string } }>("location", "name city")
  .lean();
Enter fullscreen mode Exit fullscreen mode

Gotcha: populate is a second query, not a join — use $lookup for filtering/aggregating across collections.


4. Aggregation + indexing

Models used below: Item { owner, status, rating, createdAt }, Review { location, rating, createdAt }, Location { owner, name, city }.

4.1 $match + $group (count, sum, avg) + $sort

await ItemModel.aggregate([
  { $match: { owner: oid(userId) } },
  { $group: {
      _id: "$status",
      count: { $sum: 1 },
      ratingSum: { $sum: "$rating" },
      avgRating: { $avg: "$rating" },
  } },
  { $sort: { count: -1 } },
]);
Enter fullscreen mode Exit fullscreen mode

Gotcha: aggregate() does NOT auto-cast — wrap ids with new Types.ObjectId(...) in $match. $avg ignores missing values.

4.2 $project, $addFields, $round

[
  { $project: { _id: 0, title: 1, rating: 1 } },
  { $addFields: {
      ratingPct: { $round: [{ $multiply: [{ $divide: ["$rating", 5] }, 100] }, 1] },
  } },
]
Enter fullscreen mode Exit fullscreen mode

Gotcha: $project with any 1 is an inclusion projection (only _id can be 0 there); $addFields keeps all other fields.

4.3 $lookup (with pipeline) + $unwind

await ReviewModel.aggregate([
  { $lookup: {
      from: "locations",                 // collection name (lowercase plural), not model name
      let: { locId: "$location" },
      pipeline: [
        { $match: { $expr: { $eq: ["$_id", "$$locId"] } } },
        { $project: { name: 1, city: 1, owner: 1 } },
      ],
      as: "loc",
  } },
  { $unwind: "$loc" },                   // drops reviews with no matching location
  { $match: { "loc.owner": oid(userId) } },
]);
Enter fullscreen mode Exit fullscreen mode

Gotcha: inside the sub-pipeline use $expr + $$var to compare to outer fields; simple form is localField/foreignField/as.

4.4 $facet (summary + histogram in one pass)

const [r] = await ItemModel.aggregate([
  { $match: { owner: oid(userId) } },
  { $facet: {
      summary: [{ $group: { _id: null, total: { $sum: 1 }, avg: { $avg: "$rating" } } }],
      ratings: [
        { $match: { rating: { $gte: 1, $lte: 5 } } },
        { $group: { _id: "$rating", count: { $sum: 1 } } },
      ],
  } },
]);
Enter fullscreen mode Exit fullscreen mode

Gotcha: each facet's output is a single document capped at 16 MB, and it can't use indexes after the first stage — $match first.

4.5 Zero-filled rating histogram (JS side)

const byRating = new Map<number, number>(r.ratings.map((x: any) => [x._id, x.count]));
const ratingHistogram = [1, 2, 3, 4, 5].map((rating) => ({ rating, count: byRating.get(rating) ?? 0 }));
Enter fullscreen mode Exit fullscreen mode

Gotcha: $group only returns buckets that exist, so fill missing stars with 0 in code.

4.6 $bucket

{ $bucket: {
    groupBy: "$rating",
    boundaries: [1, 2, 3, 4, 5, 6],   // [1,2) [2,3) ... [5,6)
    default: "unrated",
    output: { count: { $sum: 1 } },
} }
Enter fullscreen mode Exit fullscreen mode

Gotcha: lower bound inclusive, upper exclusive; without default, a value outside the boundaries throws.

4.7 $dateTrunc (by day or month)

{ $group: {
    _id: { $dateTrunc: { date: "$createdAt", unit: "month" } },  // or "day"
    count: { $sum: 1 },
} },
{ $sort: { _id: 1 } }
Enter fullscreen mode Exit fullscreen mode

Gotcha: needs MongoDB 5.0+; add timezone: "Asia/Kolkata" to bucket by local days (default UTC).

4.8 Top-N per group (verify server >= 5.2)

await ReviewModel.aggregate([
  { $group: {
      _id: "$location",
      top: { $topN: { n: 3, sortBy: { rating: -1, createdAt: -1 }, output: "$$ROOT" } },
  } },
]);
Enter fullscreen mode Exit fullscreen mode

Gotcha: older servers: $sort → $group with $push → $project with { $slice: ["$arr", 3] }.

4.9 Per-owner stats

await ItemModel.aggregate([
  { $group: {
      _id: "$owner",
      total: { $sum: 1 },
      done: { $sum: { $cond: [{ $eq: ["$status", "done"] }, 1, 0] } },
      avgRating: { $avg: "$rating" },
  } },
  { $addFields: { avgRating: { $round: ["$avgRating", 1] } } },
  { $sort: { total: -1 } },
]);
Enter fullscreen mode Exit fullscreen mode

Gotcha: $cond: [cond, then, else] is the conditional-count trick; $round returns null for null input.

4.10 explain("executionStats")

const plan = await ItemModel.find({ owner: oid(userId), status: "open" })
  .sort({ createdAt: -1 })
  .explain("executionStats");
// aggregate: ItemModel.aggregate([...]).explain("executionStats")
Enter fullscreen mode Exit fullscreen mode

Gotcha: compare totalDocsExamined to nReturned — a big gap means a poor index.

4.11 COLLSCAN vs IXSCAN

winningPlan.stage / inputStage.stage:
  COLLSCAN -> reads every document (no usable index)
  IXSCAN   -> uses an index
  FETCH    -> then loads the documents
  SORT     -> in-memory sort (index doesn't cover the sort)
Enter fullscreen mode Exit fullscreen mode

Gotcha: want IXSCAN with totalKeysExamined ≈ nReturned and no SORT stage.

4.12 Compound index order (ESR rule)

// Equality -> Sort -> Range
itemSchema.index({ owner: 1, status: 1, createdAt: -1 });
// find({ owner, status }).sort({ createdAt: -1 })           -> equality, equality, sort
// find({ owner, createdAt: { $gte: d } }).sort({ rating:-1 }) -> owner(E), rating(S), createdAt(R)
Enter fullscreen mode Exit fullscreen mode

Gotcha: an index serves its prefixes only — {owner, status} can't help a query on status alone.


5. Auth

5.1 bcrypt hash + compare

import bcrypt from "bcryptjs";

const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(password, user.password); // needs .select("+password")

// unknown email: still burn the time
await bcrypt.compare(password, DUMMY_HASH);
Enter fullscreen mode Exit fullscreen mode

Gotcha: bcrypt ignores bytes past 72 — cap password length in the Zod schema (Buffer.byteLength(p) <= 72).

5.2 JWT sign + verify (HS256, expiry)

import jwt from "jsonwebtoken";

const accessToken = jwt.sign(
  { sub: userId, sid: sessionId, type: "access" },
  env.ACCESS_TOKEN_SECRET,
  { algorithm: "HS256", expiresIn: 900 },          // seconds
);

const payload = jwt.verify(token, env.ACCESS_TOKEN_SECRET, { algorithms: ["HS256"] });
Enter fullscreen mode Exit fullscreen mode

Gotcha: always pass algorithms on verify; use different secrets for access and refresh, and check a type claim so one can't be used as the other.

5.3 authenticate middleware (+ session check)

export const authenticate: RequestHandler = asyncHandler(async (req, _res, next) => {
  const m = req.get("authorization")?.match(/^Bearer ([^\s]+)$/i);
  if (!m) throw new ApiError(401, "Authentication required", "UNAUTHORIZED");

  const claims = verifyAccessToken(m[1]!);           // verifies + checks type === "access"

  const session = await SessionModel.findOne({
    _id: claims.sid,
    userId: claims.sub,
    revokedAt: { $exists: false },
    expiresAt: { $gt: new Date() },
  }).select("_id");
  if (!session) throw new ApiError(401, "Session is no longer active", "UNAUTHORIZED");

  req.auth = { userId: claims.sub, sessionId: claims.sid };
  next();
});
Enter fullscreen mode Exit fullscreen mode

Gotcha: this DB check is what makes logout/revoke immediate; skip it and a stolen access token lives until expiry. Type req.auth via declare global { namespace Express { interface Request { auth?: {...} } } }.

5.4 Role-based authorize (my addition, not in repo)

export const authorize = (...roles: Array<"user" | "admin">): RequestHandler =>
  asyncHandler(async (req, _res, next) => {
    const user = await UserModel.findById(req.auth!.userId).select("role").lean();
    if (!user || !roles.includes(user.role)) throw new ApiError(403, "Forbidden", "FORBIDDEN");
    next();
  });

router.delete("/:id", authenticate, authorize("admin"), remove);
Enter fullscreen mode Exit fullscreen mode

Gotcha: my token carries no role claim, so this reads the role from the DB (always fresh); put role in the JWT only if you accept stale roles until expiry.

5.5 Refresh cookie options options

res.cookie("refreshToken", token, {
  httpOnly: true,                           // JS can't read it
  secure: env.AUTH_COOKIE_SECURE,           // HTTPS only
  sameSite: env.AUTH_COOKIE_SAME_SITE,      // "lax" | "strict" | "none"
  path: "/api/auth",                        // only sent to auth routes
  maxAge: env.REFRESH_TOKEN_TTL_SECONDS * 1000,   // ms
});

res.clearCookie("refreshToken", { httpOnly: true, secure: env.AUTH_COOKIE_SECURE,
  sameSite: env.AUTH_COOKIE_SAME_SITE, path: "/api/auth" });
Enter fullscreen mode Exit fullscreen mode

Gotcha: clearCookie must repeat the same path/secure/sameSite or the browser keeps the cookie; sameSite: "none" requires secure: true.

5.6 Create session (stable session id)

export async function createSession(userId: string, req: Request) {
  const sessionId = new Types.ObjectId();                      // sid is created up front
  const tokens = signTokenPair(userId, sessionId.toString());

  await SessionModel.create({
    _id: sessionId,
    userId: new Types.ObjectId(userId),
    tokenHash: sha256(tokens.refreshToken),                    // never store the raw token
    userAgent: req.get("user-agent"),
    ip: req.ip,
    lastUsedAt: new Date(),
    expiresAt: new Date(Date.now() + env.REFRESH_TOKEN_TTL_SECONDS * 1000),
  });
  return tokens;
}
// sha256 = (s: string) => createHash("sha256").update(s).digest("hex")
Enter fullscreen mode Exit fullscreen mode

Gotcha: one session doc per device; refresh rotates the token inside the same session, so sid never changes.

5.7 Refresh rotation + previous-hash replay detection

const claims = verifyToken(token, env.REFRESH_TOKEN_SECRET, "refresh");
const session = await SessionModel.findById(claims.sid);
// ...reject if missing / wrong user / revokedAt / expired

const supplied = sha256(token);
if (session.previousTokenHash && supplied === session.previousTokenHash) {
  await SessionModel.updateOne({ _id: session._id }, { $set: { revokedAt: new Date() } }); // replay
  throw new ApiError(401, "Invalid or expired token", "UNAUTHORIZED");
}
if (supplied !== session.tokenHash) throw new ApiError(401, "Invalid or expired token", "UNAUTHORIZED");

const next = signTokenPair(claims.sub, session._id.toString());
const updated = await SessionModel.findOneAndUpdate(
  { _id: session._id, tokenHash: supplied, revokedAt: { $exists: false } },   // compare-and-set
  { $set: { tokenHash: sha256(next.refreshToken), previousTokenHash: supplied, lastUsedAt: new Date() } },
  { new: true },
);
if (!updated) { /* lost the race: revoke this session, throw 401 */ }
return next;
Enter fullscreen mode Exit fullscreen mode

Gotcha: concurrent refreshes look like replay and kill that device's session — the client must serialize refreshes (see Part 2, single-flight + navigator.locks). Compare hashes with timingSafeEqual in real code (hashesEqual in my repo).

5.8 Why jti

jwt.sign({ sub, sid, type: "refresh", jti: randomUUID() }, env.REFRESH_TOKEN_SECRET, { algorithm: "HS256", expiresIn });
Enter fullscreen mode Exit fullscreen mode

Gotcha: two refresh tokens minted in the same second with identical claims would be byte-identical (same hash), so rotation/replay detection would break — jti makes every token unique.

5.9 Logout (current device)

export const logout = asyncHandler(async (req, res) => {
  const bearer = req.get("authorization")?.match(/^Bearer ([^\s]+)$/i)?.[1];
  const token = cookie.parse(req.headers.cookie ?? "").refreshToken;
  const claims =
    (bearer && verifyAccessTokenForLogout(bearer)) || (token && verifyRefreshTokenForLogout(token));

  if (claims) {
    await SessionModel.updateOne(
      { _id: claims.sid, userId: claims.sub, revokedAt: { $exists: false } },
      { $set: { revokedAt: new Date() } },
    );
  }
  clearRefreshCookie(res);
  res.status(204).end();
});
Enter fullscreen mode Exit fullscreen mode

Gotcha: logout is idempotent and falls back to the refresh cookie when the access token has expired; always bind sid to userId.

5.10 Logout-all

export const revokeAllSessions = (userId: string) =>
  SessionModel.updateMany(
    { userId: new Types.ObjectId(userId), revokedAt: { $exists: false } },
    { $set: { revokedAt: new Date() } },
  );

router.post("/logout-all", authenticate, logoutAll);
Enter fullscreen mode Exit fullscreen mode

Gotcha: also call it after a password change.

5.11 Session list + revoke

export const listSessions = asyncHandler(async (req, res) => {
  const { userId, sessionId } = req.auth!;
  const sessions = await SessionModel.find({
    userId, revokedAt: { $exists: false }, expiresAt: { $gt: new Date() },
  }).select("_id userAgent ip createdAt lastUsedAt expiresAt").sort({ createdAt: -1 });

  res.json({ success: true, data: { sessions: sessions.map((s) => ({
    ...s.toJSON(), current: s._id.toString() === sessionId,
  })) } });
});

// revoke: findOneAndUpdate({ _id: req.params.id, userId: req.auth!.userId }, { $set: { revokedAt: new Date() } })
// -> null => 404
Enter fullscreen mode Exit fullscreen mode

Gotcha: include userId in the revoke filter or any user can kill any session id; 404 (not 403) when it's not theirs.

Top comments (0)