Security basics every engineer needs path · Application flaws · Topic 9 of 16

Broken access control

Not started

Access control decides whether an authenticated user may do this action on this object. It breaks in mundane ways: a handler that loads a record by ID and returns it, an admin route that assumes only admins know its URL, an update endpoint that saves whatever fields arrive. None of these needs a sophisticated attacker, only one who changes a number in a URL.

Why it matters: Broken access control is A01 in the OWASP Top 10:2025. It needs no clever exploit, just a changed ID or a direct API call, and scanners rarely find it because only you know who should see what.

  1. 1IDOR is the classic

    An insecure direct object reference: the server fetches a record by the ID in the URL without checking that it belongs to the caller.

  2. 2Function-level checks

    Admin and internal endpoints must check roles themselves. Being unlinked from the UI is not protection.

  3. 3Mass assignment

    Binding a whole request body to a model lets users set fields like role or owner_id. Accept an explicit list of fields.

  4. 4Test with two users

    For every endpoint, a test that user B can't read or change user A's objects catches most of these bugs.

1

How it works

The explanation, with examples you can run.

The common shapes

Insecure direct object references (IDOR). GET /api/orders/7731 returns order 7731 to anyone logged in. The fix is to make the ownership condition part of the lookup, so the wrong user finds nothing:

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

Returning 404 rather than 403 for someone else's object also avoids confirming that it exists.

Missing function-level checks. /admin/export-users isn't linked anywhere for normal users, but anyone who finds it in a JavaScript bundle can call it. Every privileged endpoint checks the role on the server, ideally through middleware applied to the whole admin router, so a new route can't forget.

Mass assignment. Frameworks that bind request bodies to models make it easy to save {"name":"Ana","role":"admin"}. Use explicit allow-lists of writable fields or separate input types per action.

Path and method gaps. The check is on GET /items/:id but not on PUT or DELETE, or on /items/:id but not /items/:id/attachments. Check per action, not per URL pattern.

Trusting the client. Prices, user IDs, roles or "isAdmin" flags taken from the request body, a hidden field, or an unsigned cookie.

Server-side request forgery (also in A01 since 2025). The server fetches a URL the user supplies and can reach internal services the user can't. Allow-list destinations, block private and metadata address ranges, and don't follow redirects blindly.

Building it so it holds

  • Deny by default, and enforce in one place: middleware or a policy function every handler calls.
  • Scope queries by tenant and owner from the authenticated session, not from request fields. In multi-tenant systems, database row-level security adds a second layer.
  • Log denials and alert on spikes; a burst of 403s and 404s across sequential IDs is someone scanning.
  • Test it: two users, every endpoint, every method. These tests are cheap and catch the bugs scanners miss, because a scanner doesn't know which records belong to whom.
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Which handler is safe?
    // Aconst doc = await db.docs.findById(req.params.id)// Bconst doc = await db.docs.findOne({ id: req.params.id, ownerId: req.user.id })// Cconst doc = await db.docs.findById(req.params.id); if (req.query.owner === "me") ...
  2. 2.A profile update endpoint does `Object.assign(user, req.body)`. What can a user do?
  3. 3.Do random UUIDs instead of sequential IDs fix IDOR?

0 of 3 right so far. Get all three to finish this topic.