1. Hashing Passwords with bcrypt
You never store a user's password. You store a one-way hash of it, so
that even if your database leaks, an attacker doesn't get plaintext passwords back —
they get hashes that are computationally expensive to reverse. Add a
passwordHash column to last week's User model:
model User {
id Int @id @default(autoincrement())
email String @unique
name String
passwordHash String
posts Post[]
createdAt DateTime @default(now())
}
npm install bcrypt jsonwebtoken
npm install -D @types/bcrypt @types/jsonwebtoken
import { Router } from "express";
import bcrypt from "bcrypt";
import { prisma } from "../lib/prisma.js";
export const authRouter = Router();
const SALT_ROUNDS = 12;
authRouter.post("/register", async (req, res, next) => {
try {
const { email, password, name } = req.body as {
email: string;
password: string;
name: string;
};
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: "Email and an 8+ character password are required" });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return res.status(409).json({ error: "An account with that email already exists" });
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const user = await prisma.user.create({
data: { email, name, passwordHash },
select: { id: true, email: true, name: true }, // never select passwordHash back out
});
res.status(201).json(user);
} catch (err) {
next(err);
}
});
bcrypt.hash(password, SALT_ROUNDS) does two things in one call: it
generates a random salt and folds it into the hash, and it runs the
hashing algorithm 2^SALT_ROUNDS times. The salt means two users with the
identical password get completely different hashes, which defeats precomputed
rainbow-table lookups. The repeated rounds make each individual guess expensive to
check, which is what makes brute-forcing a leaked hash impractical at scale even though
it's mathematically possible in principle. 12 rounds is a reasonable
default in 2026 — high enough to be slow for an attacker, low enough (tens of
milliseconds) not to noticeably slow down a real login request.
select the hash back to the client
Even though it's already hashed, a leaked passwordHash is still a valuable target for offline cracking. Get in the habit of explicitly listing the fields you return from any route touching User — select: { id, email, name } — rather than returning the raw Prisma object and hoping nobody notices the extra field.
2. Issuing JWTs on Login
A JWT (JSON Web Token) is a compact, signed string that encodes a payload — typically the user's ID and a few claims — plus a signature the server can verify without hitting the database again. On login, you check the password against the stored hash, then sign a token containing enough information to identify the user on future requests:
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is not set");
}
authRouter.post("/login", async (req, res, next) => {
try {
const { email, password } = req.body as { email: string; password: string };
const user = await prisma.user.findUnique({ where: { email } });
// Same generic error for "no such user" and "wrong password" -- see the callout below.
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: "Invalid email or password" });
}
const token = jwt.sign(
{ sub: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: "1h" },
);
res.json({ token, user: { id: user.id, email: user.email, name: user.name } });
} catch (err) {
next(err);
}
});
bcrypt.compare(password, user.passwordHash) — not manually re-hashing the
submitted password and checking string equality — is the correct way to check a
password. compare extracts the salt and round count embedded in the
stored hash and reruns the exact same algorithm on the candidate password, then does a
constant-time comparison of the results, which is both correct and resistant to timing
attacks that a naive === on two strings would leak information through.
sub ("subject") is the conventional JWT claim name for the identity the
token represents; keep the payload small — it's base64-encoded, not encrypted, so
anyone holding the token can decode and read it, even though they can't forge a valid
signature without the secret.
Returning "no user with that email" versus "wrong password" as distinct errors lets an attacker enumerate valid emails in your system one guess at a time. Returning the same generic 401 for both cases — as the snippet above does — costs you nothing in usability and closes that leak.
3. Verifying JWTs with an Auth Middleware
A client sends its JWT back on every subsequent request, conventionally in an
Authorization: Bearer <token> header. An Express middleware reads
that header, verifies the signature, and either lets the request through or rejects
it — this is where centralized error-handling from Week 2 and the middleware pattern
from Week 2 both come together:
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET!;
interface JwtPayload {
sub: number;
email: string;
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing or malformed Authorization header" });
return;
}
const token = header.slice("Bearer ".length);
try {
const payload = jwt.verify(token, JWT_SECRET) as JwtPayload;
req.user = { id: payload.sub, email: payload.email };
next();
} catch (err) {
// jwt.verify throws TokenExpiredError, JsonWebTokenError, etc. -- all mean "not authenticated"
res.status(401).json({ error: "Invalid or expired token" });
}
}
jwt.verify recomputes the signature over the token's header and payload
using JWT_SECRET and compares it against the signature embedded in the
token; it throws if they don't match, or if the token's exp claim (set
automatically by expiresIn) has already passed. There is no database
lookup here at all — verification is pure computation against the secret, which is
exactly what makes JWTs cheap to check on every request compared to a session store
that requires a lookup per request.
A stolen JWT is valid until it expires — there's no built-in way to revoke a single token early, since verification never touches a database. Keeping expiresIn short (an hour, in the example) bounds the blast radius of a leaked token. Production systems typically pair a short-lived access token like this one with a separate, longer-lived refresh token stored server-side, which is out of scope for this week but worth knowing the shape of.
4. Protecting Routes & Typing req.user
With the middleware written, protecting a route is one line — mount
requireAuth before the handler you want gated, either per-route or across
a whole Router():
import { requireAuth } from "../middleware/requireAuth.js";
postsRouter.post("/", requireAuth, async (req, res, next) => {
try {
const post = await prisma.post.create({
data: {
title: req.body.title,
content: req.body.content,
authorId: req.user!.id, // set by requireAuth -- guaranteed to exist past this point
},
});
res.status(201).json(post);
} catch (err) {
next(err);
}
});
The middleware sets req.user, but Express's own Request type
doesn't know that field exists — you saw a similar gap in Week 5 when a relation you
hadn't included simply wasn't on the type. Fix it once, globally, with
TypeScript's declaration merging so every route handler in the app sees a correctly
typed req.user:
declare global {
namespace Express {
interface Request {
user?: {
id: number;
email: string;
};
}
}
}
export {}; // makes this a module, which is required for `declare global` to work
user is declared optional (user?:) because it's genuinely
absent on any request that never passed through requireAuth — a public
route has no req.user at all. That's why the handler above uses the
non-null assertion req.user!.id: by the time code runs past
requireAuth in the middleware chain, req.user is guaranteed
to be set, even though TypeScript can't prove that fact across the middleware boundary
on its own.
5. Hands-on Exercise
Add register, login, and a protected /me route to the blog API
Wire up real authentication end to end on top of the User model, and confirm both the happy path and the rejection paths behave correctly.
Requirements:
- Add
passwordHashto theUsermodel and migrate, then implementPOST /auth/registerexactly as in Section 1, including the duplicate-email check. - Implement
POST /auth/loginper Section 2, readingJWT_SECRETfrom an environment variable (use a.envfile anddotenv, and confirm the server refuses to start if the variable is missing). - Write
requireAuthper Section 3, plus theexpress.d.tsdeclaration merge from Section 4. - Add a protected
GET /auth/meroute that returns the authenticated user'sid,email, andnameusing onlyreq.user— no extra database query. - Manually test all four failure cases with a REST client: missing header, malformed header (no
Bearerprefix), a token signed with a different secret, and an expired token (setexpiresIn: "1s"temporarily to trigger it). Confirm each returns 401.
For the "signed with a different secret" test case, temporarily write a throwaway one-off script that calls jwt.sign(...) with a different string literal as the secret, and paste that token into your request. This is the exact failure mode a stolen or forged token would hit, and confirming your middleware rejects it is more convincing than reading the code and assuming it works.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does bcrypt embed a random salt in every hash instead of hashing the password alone?
Why does bcrypt embed a random salt in every hash instead of hashing the password alone?
Without a salt, two users with the same password would produce identical hashes, and an attacker could precompute a lookup table (a "rainbow table") mapping common passwords to their hashes once and reuse it against every leaked database they find. A random salt folded into each hash means the same password produces a different hash for every user, which makes precomputed tables useless — the attacker would need to brute-force each hash individually.
Q2
Why use bcrypt.compare() instead of hashing the submitted password yourself and checking it with === against the stored hash?
Why use bcrypt.compare() instead of hashing the submitted password yourself and checking it with === against the stored hash?
You can't simply re-hash the candidate password with a fresh call to bcrypt.hash, because that would generate a new random salt and produce a different result even for the correct password — the salt used has to come from the stored hash itself. bcrypt.compare extracts that embedded salt and round count, reruns the same algorithm, and performs the final comparison in constant time, which also avoids leaking timing information a naive string comparison could expose.
Q3
Why does jwt.verify not need a database lookup to reject a tampered or expired token?
Why does jwt.verify not need a database lookup to reject a tampered or expired token?
A JWT carries everything needed to verify it inside itself: the payload, and a signature computed over that payload using a secret only the server knows. jwt.verify recomputes that signature locally and compares it to the one on the token — if anything in the payload changed, or the wrong secret was used to sign it, the signatures won't match, and the exp claim is checked the same way, all without touching the database.
Q4
Why is req.user declared as optional (user?:) in the Express type augmentation, and why is req.user!.id still safe to use inside a route protected by requireAuth?
Why is req.user declared as optional (user?:) in the Express type augmentation, and why is req.user!.id still safe to use inside a route protected by requireAuth?
req.user is genuinely absent on any request that never passed through requireAuth — a public route's handler has no such field — so making it optional accurately reflects that across the whole app. The non-null assertion is safe specifically inside a route that has requireAuth earlier in its middleware chain, because that middleware either sets req.user and calls next(), or returns a 401 response and never calls next() at all — so any handler code that runs afterward is only reachable once req.user is guaranteed to be set.