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.
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.tsimport { 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
}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 };
}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" });
};
}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;
}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"));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.
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));
// });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);
});
});