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:locationIdshould belocation;$ratingsshould be$rating;lteneeds$lte; facet keyratinggmust beratings; list filters useowner/title, whichReviewdoesn't have. -
review.schemas.tsreply is{ owner, text }but the model's reply is{ owner, reply }. -
config/redis.ts:maxRetriesPerRequest: 0→ BullMQ workers neednull. -
config/env.ts:REDIS_PORThas.min(6379)— probably meant a plain port range.
Table of contents
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
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"
}
}
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"]
}
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;
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);
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); });
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); };
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";
}
}
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");
};
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(); };
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");
};
Gotcha: mount after all routes, before errorHandler.
2.6 helmet (not in my repo — verify)
import helmet from "helmet";
app.use(helmet());
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,
}));
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")),
});
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);
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();
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);
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
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 } });
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
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) });
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) };
}
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"),
});
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" };
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();
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();
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 } },
]);
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] },
} },
]
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) } },
]);
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 } } },
],
} },
]);
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 }));
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 } },
} }
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 } }
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" } },
} },
]);
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 } },
]);
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")
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)
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)
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);
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"] });
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();
});
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);
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" });
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")
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;
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 });
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();
});
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);
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
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)