How to buildOctober 11, 20268 min read
Spring BootStep 6 of 7

Authorization: roles, permissions and server-side access checks

In short

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.

How-toLevel: IntermediateAuthorizationPractice: ~1–2 h

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 requirePermission middleware and the right to a record with the scoped findLeadFor lookup
  • choose between 401, 403 and 404 for each denial and explain the choice
  • write supertest tests for denials: not logged in, no permission, someone else’s record, tampered body fields

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
  1. 01Why frontend checks don’t count
  2. 02RBAC: roles, permissions and the access matrix
  3. 03Route-level checks: middleware
  4. 04Object-level checks: “is this my record?” (IDOR)
  5. 05401, 403 or 404: what to return
  6. 06Testing access denials

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).

src/permissions.ts
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();
  };
}
Role matrix and the permission-check middleware
  • Check permissions, not role names: can(user, "lead:delete") instead of user.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 — tsc will 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.

src/leads-routes.ts
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();
});
Lead routes: action permission in middleware, record access in the lookup

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.

src/app.ts
--- 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 change to app.ts from the authentication lesson

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.

src/leads.ts
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);
}
Lead store: visibility is defined in one place
  • Filter in the query, not in the UI: a manager’s GET /leads list is built with the same isVisibleTo condition 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 PATCH handler changes only status. Copying the whole request body into the record (Object.assign) lets a manager send assigneeId and 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 userId in 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.

test/leads.test.ts
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);
  });
});
Permission matrix, 401, 403, IDOR and tampered fields
terminal
$ npx vitest run test/leads.test.ts
 RUN  v5.0.3 ~/auth-demo

 Test Files  1 passed (1)
      Tests  17 passed (17)
17 tests: 6 matrix rows and 11 scenarios over HTTP

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 requirePermission is 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.