Week 6: JWT Authentication & Password Hashing

Every model you've built so far — the User from Week 4, the relations from Week 5 — has assumed the app already knows who's making a request. This week you'll make that assumption true: hashing passwords with bcrypt instead of ever storing plaintext, issuing signed JWTs on login, and writing an Express middleware that verifies a token, attaches the authenticated user to req, and rejects anyone who doesn't have a valid one. Everything here is the foundation Week 7 builds on directly — role-based access control is just another middleware layered on top of the req.user this week establishes, and OAuth in that same week still ends by issuing one of these JWTs.

Module 5 of 22 Week 6 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Hash and verify passwords with bcrypt, and explain why you never store or compare plaintext
  • Issue signed JWTs on login and verify them on incoming requests
  • Write an Express auth middleware that protects routes and attaches the current user to req

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:

prisma/schema.prisma
model User {
  id           Int      @id @default(autoincrement())
  email        String   @unique
  name         String
  passwordHash String
  posts        Post[]
  createdAt    DateTime @default(now())
}
terminal
npm install bcrypt jsonwebtoken
npm install -D @types/bcrypt @types/jsonwebtoken
src/routes/auth.ts — register
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.

Never 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 Userselect: { 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:

src/routes/auth.ts — login
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.

Don't reveal which part of the login failed

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:

src/middleware/requireAuth.ts
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.

Short-lived tokens, not indefinite ones

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():

src/routes/posts.ts
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:

src/types/express.d.ts
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

Hands-on

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:

  1. Add passwordHash to the User model and migrate, then implement POST /auth/register exactly as in Section 1, including the duplicate-email check.
  2. Implement POST /auth/login per Section 2, reading JWT_SECRET from an environment variable (use a .env file and dotenv, and confirm the server refuses to start if the variable is missing).
  3. Write requireAuth per Section 3, plus the express.d.ts declaration merge from Section 4.
  4. Add a protected GET /auth/me route that returns the authenticated user's id, email, and name using only req.user — no extra database query.
  5. Manually test all four failure cases with a REST client: missing header, malformed header (no Bearer prefix), a token signed with a different secret, and an expired token (set expiresIn: "1s" temporarily to trigger it). Confirm each returns 401.
Hint

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?

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?

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?

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?

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.