AdvancedTypeScript · Lesson 7 of 10

Authentication & Authorization

Hash passwords with scrypt, issue and verify JWTs, and protect Express routes with login and role checks.

Authentication answers "who are you?"; authorization answers "are you allowed to do this?". Never store passwords: store a slow, salted hash. Node's built-in scrypt is designed to be slow for attackers, a random salt makes identical passwords hash differently, and timingSafeEqual compares without leaking timing information. On a failed login, give the same message whether the email or the password was wrong.

After login, the API returns a token the client sends back in Authorization: Bearer <token>. A JSON Web Token (JWT) carries claims (the user id in sub, the role, an expiry) and a signature made with a server secret, so the server can trust it without a database lookup. The payload is only encoded, not encrypted: anyone can read it, but changing it breaks the signature. Keep tokens short-lived, keep the secret in the environment, and pin the algorithm when verifying.

Middleware applies the rules: requireAuth verifies the token and puts the user on req.user (a declare global block tells TypeScript about it) or answers 401; requireRole("teacher") answers 403 for anyone else. Role checks are not enough on their own: a parent may read results, but only their own child's. That is object-level authorization, and it is one of the most common security bugs in real APIs.

TerminalShell
npm install express jose
npm install --save-dev @types/express
# A long random secret, kept out of the code (e.g. in .env or your host's settings)
export JWT_SECRET="$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))')"
npx tsx src/server.ts
src/passwords.tsTypeScript
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, keylen: number) => Promise<Buffer>;

// Never store passwords. Store a slow, salted hash: "salt:hash" in hex.
export async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16);
  const hash = await scryptAsync(password, salt, 64);
  return `${salt.toString("hex")}:${hash.toString("hex")}`;
}

export async function verifyPassword(password: string, stored: string): Promise<boolean> {
  const [saltHex, hashHex] = stored.split(":");
  const expected = Buffer.from(hashHex, "hex");
  const actual = await scryptAsync(password, Buffer.from(saltHex, "hex"), expected.length);
  return timingSafeEqual(actual, expected);         // constant time: doesn't leak how many bytes matched
}
src/tokens.tsTypeScript
import { SignJWT, jwtVerify } from "jose";

export type Role = "teacher" | "parent" | "student";

export interface AuthUser {
  id: number;
  role: Role;
}

function secretKey(): Uint8Array {
  const secret = process.env.JWT_SECRET;
  if (!secret || secret.length < 32) throw new Error("JWT_SECRET must be set to at least 32 characters");
  return new TextEncoder().encode(secret);
}

export async function signToken(user: AuthUser): Promise<string> {
  return new SignJWT({ role: user.role })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(String(user.id))
    .setIssuedAt()
    .setExpirationTime("1h")                          // short-lived: a stolen token stops working soon
    .sign(secretKey());
}

// Throws if the signature is wrong, the token was changed, or it has expired.
export async function verifyToken(token: string): Promise<AuthUser> {
  const { payload } = await jwtVerify(token, secretKey(), { algorithms: ["HS256"] });
  const role = payload.role;
  if (role !== "teacher" && role !== "parent" && role !== "student") throw new Error("bad role");
  return { id: Number(payload.sub), role };
}
src/auth.tsTypeScript
import type { NextFunction, Request, Response } from "express";
import { type AuthUser, type Role, verifyToken } from "./tokens";

// Teach TypeScript that authenticated requests carry a user.
declare global {
  namespace Express {
    interface Request {
      user?: AuthUser;
    }
  }
}

// Authentication: who are you? 401 if we can't tell.
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.get("Authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : "";
  try {
    req.user = await verifyToken(token);
    next();
  } catch {
    res.status(401).json({ error: "Please log in" });
  }
}

// Authorization: are you allowed to do this? 403 if not.
export function requireRole(...roles: Role[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (req.user && roles.includes(req.user.role)) return next();
    res.status(403).json({ error: "You are not allowed to do that" });
  };
}
src/app.tsTypeScript
import express from "express";
import { requireAuth, requireRole } from "./auth";
import { verifyPassword } from "./passwords";
import { type Role, signToken } from "./tokens";

export interface User {
  id: number;
  email: string;
  passwordHash: string;
  role: Role;
}

export function createApp(users: User[]) {
  const app = express();
  app.use(express.json());
  const grades: { studentId: number; subject: string; score: number }[] = [];

  app.post("/login", async (req, res) => {
    const { email, password } = req.body ?? {};
    const user = users.find((u) => u.email === String(email).toLowerCase());
    // Same message whether the email or the password was wrong: don't reveal which accounts exist.
    if (!user || typeof password !== "string" || !(await verifyPassword(password, user.passwordHash))) {
      return res.status(401).json({ error: "Wrong email or password" });
    }
    res.json({ token: await signToken({ id: user.id, role: user.role }) });
  });

  app.get("/me", requireAuth, (req, res) => {
    res.json(req.user);
  });

  app.get("/grades", requireAuth, requireRole("teacher"), (_req, res) => {
    res.json(grades);
  });

  app.post("/grades", requireAuth, requireRole("teacher"), (req, res) => {
    const { studentId, subject, score } = req.body ?? {};
    if (!Number.isInteger(studentId) || typeof subject !== "string" || !Number.isInteger(score)) {
      return res.status(400).json({ error: "studentId, subject and score are required" });
    }
    grades.push({ studentId, subject, score });
    res.status(201).json({ studentId, subject, score });
  });

  return app;
}
src/server.tsTypeScript
import { createApp } from "./app";
import { hashPassword } from "./passwords";

// Demo accounts; a real app loads users from the database.
const app = createApp([
  { id: 1, email: "teacher@school.tz", passwordHash: await hashPassword("chalk-and-board-2026"), role: "teacher" },
  { id: 2, email: "amina@school.tz", passwordHash: await hashPassword("photosynthesis!"), role: "student" },
]);
app.listen(3000, () => console.log("API on http://localhost:3000"));
test/auth.test.tsTypeScript
import { after, before, describe, it } from "node:test";
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import { createApp } from "../src/app";
import { hashPassword, verifyPassword } from "../src/passwords";

process.env.JWT_SECRET = "test-secret-that-is-at-least-32-characters-long";
let server: Server;
let base: string;

before(async () => {
  server = createApp([
    { id: 1, email: "teacher@school.tz", passwordHash: await hashPassword("chalk"), role: "teacher" },
    { id: 2, email: "amina@school.tz", passwordHash: await hashPassword("leaf"), role: "student" },
  ]).listen(0);
  await new Promise((resolve) => server.once("listening", resolve));
  base = `http://localhost:${(server.address() as AddressInfo).port}`;
});
after(() => server.close());

async function login(email: string, password: string): Promise<Response> {
  return fetch(`${base}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) });
}
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });

describe("passwords", () => {
  it("hashes with a random salt and verifies", async () => {
    const a = await hashPassword("secret");
    assert.notEqual(a, await hashPassword("secret"));
    assert.equal(await verifyPassword("secret", a), true);
    assert.equal(await verifyPassword("Secret", a), false);
  });
});

describe("login and roles", () => {
  it("rejects a wrong password with 401", async () => {
    assert.equal((await login("teacher@school.tz", "nope")).status, 401);
  });

  it("401 without a token, 403 with the wrong role, 200 for a teacher", async () => {
    assert.equal((await fetch(`${base}/grades`)).status, 401);
    const { token: studentToken } = await (await login("amina@school.tz", "leaf")).json();
    assert.equal((await fetch(`${base}/grades`, { headers: auth(studentToken) })).status, 403);
    const { token: teacherToken } = await (await login("teacher@school.tz", "chalk")).json();
    assert.equal((await fetch(`${base}/grades`, { headers: auth(teacherToken) })).status, 200);
    assert.deepEqual(await (await fetch(`${base}/me`, { headers: auth(teacherToken) })).json(), { id: 1, role: "teacher" });
  });

  it("rejects a token whose payload was edited", async () => {
    const { token } = await (await login("amina@school.tz", "leaf")).json();
    const [header, , signature] = token.split(".");
    const forged = Buffer.from(JSON.stringify({ sub: "2", role: "teacher", exp: 9999999999 })).toString("base64url");
    assert.equal((await fetch(`${base}/grades`, { headers: auth(`${header}.${forged}.${signature}`) })).status, 401);
  });
});

Key points

  • Store salted, slow password hashes (scrypt) and compare them with timingSafeEqual.
  • JWTs are signed, not encrypted: short expiry, secret from the environment, algorithm pinned.
  • 401 means "not logged in", 403 means "not allowed"; check ownership as well as the role.

Exercise

Add GET /students/:id/results. Teachers can see any student, students only themselves, and parents only the children linked to them in a families list. Put the rule in a pure canViewStudent(user, studentId, families) function and test every case.

Show solution

Try the exercise yourself first — then compare your approach with this one.

The rule lives in one pure function, so every case is a one-line test, and the switch over the Role union means TypeScript will complain if a new role is added without a rule. The route calls it after requireAuth: the token proves who the user is, and canViewStudent decides whether that user may see this particular student.

src/access.tsTypeScript
import type { AuthUser } from "./tokens";

export interface Family {
  parentId: number;
  studentIds: number[];
}

// Object-level authorization: the role alone isn't enough, the user must be linked to THIS student.
export function canViewStudent(user: AuthUser, studentId: number, families: Family[]): boolean {
  switch (user.role) {
    case "teacher":
      return true;
    case "student":
      return user.id === studentId;
    case "parent":
      return families.some((f) => f.parentId === user.id && f.studentIds.includes(studentId));
  }
}

// In createApp (app.ts), with `families: Family[]` passed in next to `users`:
//
//   app.get("/students/:id/results", requireAuth, (req, res) => {
//     const studentId = Number(req.params.id);
//     if (!canViewStudent(req.user!, studentId, families)) {
//       return res.status(403).json({ error: "You can only see your own family's results" });
//     }
//     res.json(grades.filter((g) => g.studentId === studentId));
//   });
test/access.test.tsTypeScript
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { canViewStudent, type Family } from "../src/access";

const families: Family[] = [{ parentId: 10, studentIds: [2, 3] }];

describe("canViewStudent", () => {
  it("lets teachers see everyone", () => {
    assert.equal(canViewStudent({ id: 1, role: "teacher" }, 99, families), true);
  });

  it("lets students see only themselves", () => {
    assert.equal(canViewStudent({ id: 2, role: "student" }, 2, families), true);
    assert.equal(canViewStudent({ id: 2, role: "student" }, 3, families), false);
  });

  it("lets parents see only their own children", () => {
    assert.equal(canViewStudent({ id: 10, role: "parent" }, 3, families), true);
    assert.equal(canViewStudent({ id: 10, role: "parent" }, 4, families), false);
    assert.equal(canViewStudent({ id: 11, role: "parent" }, 2, families), false);
  });
});

Check your understanding

  1. How should passwords be stored?

  2. Can a user read the contents of their JWT?

  3. A logged-in student calls a teacher-only route. Which status fits?

  4. Why does the login route give the same error for a wrong email and a wrong password?

Ask AI