Authorization decides what a user may do, and it is enforced only on the server — on every request. Permissions are easiest to describe as a “role → allowed actions” matrix checked in route middleware, while access to a specific record is checked separately by limiting the data query itself to records the user may see. Without login the server answers 401, without permission for the action 403, and another user’s record is best returned as 404 so its existence isn’t confirmed.
After this you will be able to
- describe a role and permission model (RBAC) as an access matrix and encode it with deny by default
- check the right to an action in the
requirePermissionmiddleware and the right to a record with the scopedfindLeadForlookup - choose between
401,403and404for each denial and explain the choice - write supertest tests for denials: not logged in, no permission, someone else’s record, tampered body fields
Know beforehand
Verified with
Node.js 24 LTS (24.18)Express 5.3express-session 1.19TypeScript 7.0Vitest 5.0supertest 7.3
Checked against docs: October 11, 2026
Contents
Why frontend checks don’t count
Hiding the “Delete” button from a manager is a UI concern, not protection. The button only sends an HTTP request, and the same request can be sent from curl, the browser console or a script with any ID substituted. The OWASP Authorization Cheat Sheet is blunt: never rely on client-side checks; access decisions are made on the server, at the API gateway or in a serverless function.
Broken Access Control is category A01 in the OWASP Top 10:2025. Its description lists exactly the mistakes this lesson is about: viewing someone else’s account by its identifier, APIs without checks for POST, PUT and DELETE, privilege escalation, tampering with a JWT, cookie or hidden field. It also lists the main measures: deny by default except for public resources; implement access control once and reuse it; enforce record ownership in the access model; include access control in unit and integration tests.
RBAC: roles, permissions and the access matrix
RBAC (Role-Based Access Control) is a model where permissions are assigned to roles and a user inherits the permissions of their role. Start with a matrix: list roles, resources and actions, and explicitly decide “allowed” or “denied” for each pair. OWASP advises doing this at design time and writing tests that check the matrix right away. Here is an admin panel for website leads:
View leads
- Admin
- All
- Analyst
- All
- Manager
- Only those assigned to them
Change status
- Admin
- Any lead
- Analyst
- No
- Manager
- Own leads only
Delete
- Admin
- Yes
- Analyst
- No
- Manager
- No
Reassign
- Admin
- Yes (exercise 3)
- Analyst
- No
- Manager
- No
In code the matrix becomes a list of permissions per role. Note lead:all: this permission lifts the “own leads only” restriction. That way the horizontal restriction (mine vs. someone else’s among users of the same role) is expressed by the same matrix as the vertical one (regular user vs. admin).
import type { NextFunction, Request, Response } from "express";
import type { Role, User } from "./users.ts";
export type Permission = "lead:read" | "lead:update" | "lead:delete" | "lead:all";
// Матрица доступа. Чего здесь нет — запрещено (deny by default).
// lead:all снимает ограничение «только назначенные мне заявки».
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: ["lead:read", "lead:update", "lead:delete", "lead:all"],
analyst: ["lead:read", "lead:all"],
manager: ["lead:read", "lead:update"],
};
export function can(user: User, permission: Permission): boolean {
return ROLE_PERMISSIONS[user.role]?.includes(permission) ?? false;
}
// Проверка на уровне функции: разрешено ли это действие вообще.
// Request<Record<string, string>> — чтобы после middleware req.params.id оставался string
export function requirePermission(permission: Permission) {
return (req: Request<Record<string, string>>, res: Response, next: NextFunction) => {
const user = res.locals.user;
if (!user) {
res.status(401).json({ error: "unauthenticated" });
return;
}
if (!can(user, permission)) {
res.status(403).json({ error: "forbidden" });
return;
}
next();
};
}- Check permissions, not role names:
can(user, "lead:delete")instead ofuser.role === "admin". A new role is one line in the matrix, not edits to a dozen conditions - Deny by default: a permission that isn’t listed doesn’t exist. The
Record<Role, …>type makes TypeScript require a matrix row for every role —tscwill point out a forgotten one - Least privilege: an analyst needs to read, not to change — OWASP advises granting exactly what the job requires
- The role is read from the store on every request: after a demotion access narrows immediately, not after the next login
Route-level checks: middleware
The first line is the right to the action itself. The OWASP API Security Top 10 calls its absence Broken Function Level Authorization: a regular user calls an admin function just by knowing the URL or switching the method from GET to DELETE. That is why requirePermission sits on every route and runs before the code reads any data.
import { Router } from "express";
import { deleteLead, findLeadFor, LEAD_STATUSES, listLeadsFor, type LeadStatus } from "./leads.ts";
import { requirePermission } from "./permissions.ts";
export const leadsRouter = Router();
leadsRouter.get("/", requirePermission("lead:read"), (req, res) => {
res.json(listLeadsFor(res.locals.user!));
});
leadsRouter.get("/:id", requirePermission("lead:read"), (req, res) => {
const lead = findLeadFor(res.locals.user!, req.params.id);
if (!lead) {
res.status(404).json({ error: "not_found" });
return;
}
res.json(lead);
});
leadsRouter.patch("/:id", requirePermission("lead:update"), (req, res) => {
const lead = findLeadFor(res.locals.user!, req.params.id);
if (!lead) {
res.status(404).json({ error: "not_found" });
return;
}
const status = req.body?.status;
if (!LEAD_STATUSES.includes(status)) {
res.status(400).json({ error: "invalid_status" });
return;
}
// Меняем только разрешённое поле: assigneeId и прочее из тела запроса игнорируются
lead.status = status as LeadStatus;
res.json(lead);
});
leadsRouter.delete("/:id", requirePermission("lead:delete"), (req, res) => {
const lead = findLeadFor(res.locals.user!, req.params.id);
if (!lead) {
res.status(404).json({ error: "not_found" });
return;
}
deleteLead(lead.id);
res.status(204).end();
});The router is plugged into the app from the lesson “Authentication: sessions, cookies and JWT” with one line. requireAuth in front of the router answers 401 to anyone not logged in and puts the user into res.locals.user.
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,5 +1,6 @@
import express, { type NextFunction, type Request, type Response } from "express";
import session from "express-session";
+import { leadsRouter } from "./leads-routes.ts";
import { findUserById, verifyCredentials, type User } from "./users.ts";
declare module "express-session" {
@@ -108,5 +109,8 @@
});
});
+ // Все маршруты заявок — только для вошедших; права проверяет сам роутер
+ app.use("/leads", requireAuth, leadsRouter);
+
return app;
}The login check is doubled here: requirePermission also answers 401 when there is no user. We verified it by removing requireAuth from the router mount: the 401 tests kept passing. This redundancy is deny by default in practice: a guard forgotten on one route doesn’t turn into open access.
Object-level checks: “is this my record?” (IDOR)
A manager has lead:update, but only for their own leads. If the server looks a lead up by the ID from the URL alone, the manager substitutes someone else’s ID and changes it. That is IDOR (Insecure Direct Object Reference), in OWASP API Security terms Broken Object Level Authorization, number one on the 2023 API risk list. OWASP describes three ingredients: an object, a reference to it (ID, UUID, file name) and a missing check that this user may work with this object.
A reliable technique from the OWASP IDOR Prevention Cheat Sheet is to look the record up among those the user can access instead of finding it first and checking later. In SQL that is one condition in the query itself: WHERE id = $1 AND assignee_id = $2 for a manager. Then someone else’s lead and a missing one give the same result, and the 404 doesn’t confirm the record exists.
import { randomUUID } from "node:crypto";
import { can } from "./permissions.ts";
import type { User } from "./users.ts";
export const LEAD_STATUSES = ["new", "in_progress", "done"] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];
export type Lead = {
id: string;
name: string;
phone: string;
status: LeadStatus;
assigneeId: string;
};
// Учебное хранилище в памяти. В реальном проекте — таблица в базе данных.
const leads = new Map<string, Lead>();
export function addLead(data: Omit<Lead, "id">): Lead {
const lead: Lead = { id: randomUUID(), ...data };
leads.set(lead.id, lead);
return lead;
}
// Единственное место, где решается, чьи заявки видит пользователь.
// В SQL это условие WHERE в самом запросе, а не фильтр после выборки.
function isVisibleTo(user: User, lead: Lead): boolean {
return can(user, "lead:all") || lead.assigneeId === user.id;
}
export function listLeadsFor(user: User): Lead[] {
return [...leads.values()].filter((lead) => isVisibleTo(user, lead));
}
// Поиск сразу в границах доступного: чужая заявка неотличима от несуществующей
export function findLeadFor(user: User, id: string): Lead | undefined {
const lead = leads.get(id);
return lead && isVisibleTo(user, lead) ? lead : undefined;
}
export function deleteLead(id: string): void {
leads.delete(id);
}- Filter in the query, not in the UI: a manager’s
GET /leadslist is built with the sameisVisibleTocondition instead of returning everything and hoping the frontend filters it - Random UUIDs instead of sequential numbers make enumeration harder but, as OWASP stresses, don’t replace access checks
- Property protection: the
PATCHhandler changes onlystatus. Copying the whole request body into the record (Object.assign) lets a manager sendassigneeIdand take over a lead — mass assignment from OWASP API3:2023 - Where possible, don’t take the ID from the request at all: “my profile” is determined by the session, not by a
userIdin the URL
401, 403 or 404: what to return
The status code is part of the API contract: the client uses it to decide whether to show a login form or an “insufficient permissions” message. RFC 9110 separates the cases like this:
No session, token expired or invalid
- Code
401 Unauthorized- Why
- No valid credentials; the client should log in again. Per RFC 9110 the response must carry
WWW-Authenticate; for Bearer tokens that is the scheme from RFC 6750
Logged in, but the role doesn’t allow the action
- Code
403 Forbidden- Why
- Credentials are present but insufficient; repeating the same request changes nothing
The record belongs to someone else or doesn’t exist
- Code
404 Not Found- Why
- RFC 9110 allows answering 404 instead of 403 to hide that the resource exists
Invalid request body (e.g. unknown status)
- Code
400 Bad Request- Why
- The denial isn’t about permissions — data validation runs after the access checks
The order of checks matters. Authentication first (401), then the right to the action (403) — it doesn’t depend on the ID and reveals nothing. Then the lookup within the accessible scope (404), and only after that data validation (400). Validating data before access lets an attacker tell from the difference in responses whether a record exists.
Testing access denials
Everyone writes tests for “allowed”; tests for “denied” are rare, although they are the ones that catch vulnerabilities. The OWASP IDOR Cheat Sheet describes the method: create at least two users, A and B, with objects owned by each, log in as A and try to read, change and delete B’s objects. The test below follows it: four users, one lead for each of two managers, and a check for every kind of denial. The login and SAME_ORIGIN helpers come from the authentication lesson.
import { randomBytes } from "node:crypto";
import request from "supertest";
import { beforeAll, describe, expect, it } from "vitest";
import { createApp } from "../src/app.ts";
import { addLead, type Lead } from "../src/leads.ts";
import { can, type Permission } from "../src/permissions.ts";
import { createUser, type Role, type User } from "../src/users.ts";
import { login, SAME_ORIGIN } from "./helpers.ts";
const app = createApp({
sessionSecret: randomBytes(32).toString("hex"),
appOrigin: "https://app.example.com",
});
const PASSWORD = "long-and-unique-passphrase";
type Who = "admin" | "analyst" | "anna" | "boris";
const cookies = {} as Record<Who, string>;
let anna: User;
let boris: User;
let annaLead: Lead;
let borisLead: Lead;
// Заголовки запроса от имени конкретного пользователя
const as = (who: Who) => ({ ...SAME_ORIGIN, Cookie: cookies[who] });
beforeAll(async () => {
await createUser("admin@example.com", PASSWORD, "admin");
await createUser("analyst@example.com", PASSWORD, "analyst");
anna = await createUser("anna@example.com", PASSWORD, "manager");
boris = await createUser("boris@example.com", PASSWORD, "manager");
annaLead = addLead({ name: "Кофейня", phone: "+7 700 000 00 01", status: "new", assigneeId: anna.id });
borisLead = addLead({ name: "Салон", phone: "+7 700 000 00 02", status: "new", assigneeId: boris.id });
for (const who of ["admin", "analyst", "anna", "boris"] as const) {
cookies[who] = (await login(app, `${who}@example.com`, PASSWORD)).cookie!;
}
});
describe("матрица прав", () => {
it.each<[Role, Permission, boolean]>([
["admin", "lead:delete", true],
["manager", "lead:update", true],
["manager", "lead:delete", false],
["manager", "lead:all", false],
["analyst", "lead:all", true],
["analyst", "lead:update", false],
])("%s → %s: %s", (role, permission, expected) => {
expect(can({ role } as User, permission)).toBe(expected);
});
});
describe("401: пользователь не вошёл", () => {
it("список заявок без cookie", async () => {
const res = await request(app).get("/leads").set(SAME_ORIGIN);
expect(res.status).toBe(401);
});
it("удаление без cookie", async () => {
const res = await request(app).delete(`/leads/${annaLead.id}`).set(SAME_ORIGIN);
expect(res.status).toBe(401);
});
});
describe("403: роль не разрешает действие", () => {
it("аналитик не меняет статус", async () => {
const res = await request(app).patch(`/leads/${annaLead.id}`).set(as("analyst")).send({ status: "done" });
expect(res.status).toBe(403);
});
it("менеджер не удаляет даже свою заявку", async () => {
const res = await request(app).delete(`/leads/${annaLead.id}`).set(as("anna"));
expect(res.status).toBe(403);
});
it("понижение роли действует со следующего запроса", async () => {
const vera = await createUser("vera@example.com", PASSWORD, "admin");
const { cookie } = await login(app, "vera@example.com", PASSWORD);
vera.role = "manager"; // роль читается из хранилища при каждом запросе
const res = await request(app).delete(`/leads/${borisLead.id}`).set({ ...SAME_ORIGIN, Cookie: cookie! });
expect(res.status).toBe(403);
});
});
describe("IDOR: чужие заявки", () => {
it("менеджер видит в списке только свои", async () => {
const res = await request(app).get("/leads").set(as("anna"));
expect(res.status).toBe(200);
expect(res.body.map((l: Lead) => l.id)).toEqual([annaLead.id]);
});
it("чужая заявка по ID — 404, как несуществующая", async () => {
const foreign = await request(app).get(`/leads/${borisLead.id}`).set(as("anna"));
const missing = await request(app).get("/leads/no-such-id").set(as("anna"));
expect(foreign.status).toBe(404);
expect(foreign.body).toEqual(missing.body);
});
it("чужую заявку нельзя изменить, подставив её ID", async () => {
const res = await request(app).patch(`/leads/${borisLead.id}`).set(as("anna")).send({ status: "done" });
expect(res.status).toBe(404);
const check = await request(app).get(`/leads/${borisLead.id}`).set(as("admin"));
expect(check.body.status).toBe("new");
});
it("assigneeId в теле запроса игнорируется", async () => {
const res = await request(app)
.patch(`/leads/${annaLead.id}`).set(as("anna"))
.send({ status: "in_progress", assigneeId: boris.id });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ status: "in_progress", assigneeId: anna.id });
});
});
describe("разрешённые действия", () => {
it("аналитик читает любую заявку", async () => {
const res = await request(app).get(`/leads/${borisLead.id}`).set(as("analyst"));
expect(res.status).toBe(200);
});
it("администратор удаляет заявку", async () => {
const lead = addLead({ name: "Студия", phone: "+7 700 000 00 03", status: "done", assigneeId: boris.id });
const res = await request(app).delete(`/leads/${lead.id}`).set(as("admin"));
expect(res.status).toBe(204);
const after = await request(app).get(`/leads/${lead.id}`).set(as("admin"));
expect(after.status).toBe(404);
});
});$ npx vitest run test/leads.test.ts
RUN v5.0.3 ~/auth-demo
Test Files 1 passed (1)
Tests 17 passed (17)To make sure the tests protect something, we broke the code one place at a time. Without the ownership condition in isVisibleTo three IDOR tests failed. With Object.assign(lead, req.body) instead of assigning the status, the assigneeId test failed. With an unscoped lookup in PATCH the test about changing someone else’s lead failed, and with lead:read guarding deletion the test “a manager can’t delete even their own lead” failed.
- Every route has an explicit permission; a route without
requirePermissionis a question for code review - Every route with an ID in the path or body looks the record up within the user’s scope
- Updates change only listed fields; responses return only the fields the client needs
- Each role has a test for at least one denied action; for IDOR — a “user A against object B” test
- Access denials are logged, and frequent denials from one user are visible to an administrator
How this was verified3 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.