Security for Developers
Access Control: IDOR, Roles and Mass Assignment
The number one web risk: checking who you are but not what you may do. IDOR with a real example, deny by default, roles and ownership checks, and mass assignment.
On this page
In one line: logging in only tells the server who you are; access control decides what you may touch. The most common bug is a server that checks the login but never checks the owner, so changing /notes/1 to /notes/2 shows someone else's data. Check on the server, on every request, deny by default, and never copy client fields straight into your records.
Lesson 3 proved who the user is. This lesson is about the next question: is this user allowed to do this to this thing? That is called authorization or access control. It is A01:2025 Broken Access Control, the number one risk in the newest OWASP Top 10. The OWASP page says it stays at #1 and that SSRF (a server tricked into fetching internal URLs, lesson 7) was rolled into this category.
We will build a tiny HTTP server with an IDOR bug, break it as a normal user, fix it, and then write a test that would have caught it. After that: deny by default, roles versus ownership, mass assignment, and the choice between 403 and 404.
Related reading: Backend Lesson 6 shows authorization as server code (roles, guards), Browser Lesson 7 covers what the front end can and cannot protect, and Full-Stack Lesson 31 has the interview side of broader server issues. Next comes Lesson 5: secrets and encryption.
What I ran / checked. All code ran with Node.js v22.22.0 on Linux, using only node:http, node:test and node:assert, with fetch as the client. I attacked only my own local toy servers on 127.0.0.1 with a random port. The outputs below are real. The files: an IDOR server in three modes (vulnerable, fixed with 404, fixed with 403), a mass assignment server in two modes, a scoped-lookup function, a deny-by-default rule table, and a node:test file with two users. Sources I read: OWASP Cheat Sheet Series pages for Authorization, Insecure Direct Object Reference Prevention, and Mass Assignment (read from the OWASP/CheatSheetSeries repository on GitHub), and the OWASP Top 10:2025 introduction (from the OWASP/Top10 repository, to confirm A01 and the 2025 list). Could not reach: the live owasp.org site and PortSwigger Web Security Academy pages (blocked from my machine), so the PortSwigger access control material is not cited here. The 403 vs 404 trade-off is general practice, not from a source I read. The "login" here is a fake token table to keep the example small; use the real login from lesson 3.
Words used in this lesson
- Authentication is proving who you are. Authorization (access control) is deciding what you may do. A system can get the first right and the second wrong.
- A resource is a thing the app protects: a note, an order, a profile.
- An owner is the user a resource belongs to. An ownership check asks "does this resource belong to the caller?".
- IDOR (Insecure Direct Object Reference) is a bug where the app uses an ID from the request (
/notes/2) to fetch a thing without checking that the caller may have it. - BOLA (Broken Object Level Authorization) is the API-world name for the same bug. You will see both in interviews.
- A role is a label like
useroradmin. RBAC (role-based access control) decides by role. - ABAC / ReBAC decide using attributes (such as "the note's owner equals the caller") or relationships ("is a member of this team").
- Deny by default means that if no rule says "yes", the answer is "no".
- Least privilege means giving each user and each piece of code only the access it needs.
- Mass assignment is a bug where the server copies every field from the request body into a record, including fields the user must not control, such as
role. - An allow-list is a list of the only things permitted. A block-list is a list of things forbidden, and anything not listed gets through.
- Forced browsing means a user typing URLs they were never shown, such as
/admin.
A tiny app with a bug
Our app has notes. Each note has an ownerId. Alice is user 1 and Bob is user 2. A login token tells the server who is calling (a stand-in for the cookie or JWT from lesson 3):
import http from "node:http";const tokens = { "token-alice": { id: 1, role: "user" }, "token-bob": { id: 2, role: "user" }, "token-root": { id: 99, role: "admin" },};const notes = new Map([ [1, { id: 1, ownerId: 1, text: "Alice: dentist on Friday" }], [2, { id: 2, ownerId: 2, text: "Bob: my bank PIN is 4921" }],]);const send = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };const server = http.createServer((req, res) => { const user = tokens[(req.headers.authorization ?? "").replace("Bearer ", "")]; if (!user) return send(res, 401, { error: "login required" }); // authentication: OK const m = req.url.match(/^\/notes\/(\d+)$/); if (req.method === "GET" && m) { const note = notes.get(Number(m[1])); return note ? send(res, 200, note) : send(res, 404, { error: "not found" }); // <-- BUG } send(res, 404, { error: "no such route" });});The code checks the login. It does not check that the note belongs to the caller. Now Alice asks for notes. I ran this with five requests:
MODE = vulnerableno login GET /notes/1 -> 401 {"error":"login required"}alice GET /notes/1 -> 200 {"id":1,"ownerId":1,"text":"Alice: dentist on Friday"}alice GET /notes/2 -> 200 {"id":2,"ownerId":2,"text":"Bob: my bank PIN is 4921"}alice GET /notes/3 -> 404 {"error":"not found"}admin GET /notes/2 -> 200 {"id":2,"ownerId":2,"text":"Bob: my bank PIN is 4921"}Line 3 is the bug. Alice is logged in as herself, she changed 1 to 2 in the URL, and she got Bob's note. No hacking tool, no clever trick. Just a different number. This is why it is common, and why it is dangerous: an attacker can write a loop from 1 to 100,000 and download everything.
The OWASP IDOR cheat sheet names three ingredients: an object (the note), a reference to it (the ID), and a missing object-level authorization check. Remove the third and you have an IDOR. The reference does not have to be a number in a URL. It can be a filename, a hidden form field, a JSON body field, or a header.
The login check ran and passed. That is why this bug hides so well. Every request had a valid user. The server just never asked the second question.
The fix: check the owner
Add the missing question: does this note belong to the caller?
const note = notes.get(Number(m[1]));if (!note) return send(res, 404, { error: "not found" });const allowed = note.ownerId === user.id || user.role === "admin"; // ownership OR adminif (!allowed) return send(res, 404, { error: "not found" });return send(res, 200, note);Run again:
MODE = fixed404no login GET /notes/1 -> 401 {"error":"login required"}alice GET /notes/1 -> 200 {"id":1,"ownerId":1,"text":"Alice: dentist on Friday"}alice GET /notes/2 -> 404 {"error":"not found"}alice GET /notes/3 -> 404 {"error":"not found"}admin GET /notes/2 -> 200 {"id":2,"ownerId":2,"text":"Bob: my bank PIN is 4921"}Alice still reads her own note. Bob's note now answers 404, exactly the same as a note that does not exist. The admin can still read it, because the rule says "owner or admin".
Better still: make the check part of the lookup
A separate if is easy to forget in the next endpoint. A safer habit is to put the owner inside the query, so a missing owner means "not found", and there is no unchecked path. The OWASP IDOR cheat sheet shows the same idea (search only among the current user's records, instead of among everything):
const findNoteFor = (user, id) => notes.find((n) => n.id === id && n.ownerId === user.id) ?? null;console.log("alice reads 1:", findNoteFor({ id: 1 }, 1));console.log("alice reads 2:", findNoteFor({ id: 1 }, 2));alice reads 1: { id: 1, ownerId: 1, text: 'A' }alice reads 2: nullWith a database it is the same: SELECT * FROM notes WHERE id = $1 AND owner_id = $2, with the owner taken from the session, never from the request. (Use parameters like lesson 1 taught.)
Another OWASP tip: do not accept the user's own ID from the client at all. Instead of POST /profile with {"userId": 1}, read the user from the session. Then there is nothing to tamper with.
Writes need the same check
Reading is only half. The cheat sheet says to verify access for read, create, update, delete, export and admin actions. A DELETE /notes/2 without an owner check lets Alice delete Bob's note. A test with two users catches both.
A test that would have caught it
The OWASP IDOR cheat sheet gives a simple test recipe: create User A and User B, give each an object, log in as A and try to reach B's objects. Here it is as a node:test file. It runs the same test against the vulnerable and the fixed server:
import test from "node:test"; import assert from "node:assert/strict";import { makeServer } from "./app.mjs"; // makeServer({ checkOwner }) builds either versionfor (const [name, checkOwner] of [["vulnerable", false], ["fixed", true]]) { test(`${name}: user A cannot read or delete user B's note`, async (t) => { const server = makeServer({ checkOwner }); await new Promise((r) => server.listen(0, r)); t.after(() => server.close()); const base = `http://127.0.0.1:${server.address().port}`; const call = (method, path, tok) => fetch(base + path, { method, headers: { authorization: "Bearer " + tok } }); assert.equal((await call("GET", "/notes/1", "token-alice")).status, 200); // own note works assert.equal((await call("GET", "/notes/2", "token-alice")).status, 404); // B's note must not assert.equal((await call("DELETE", "/notes/2", "token-alice")).status, 404); assert.equal((await call("GET", "/notes/2", "token-bob")).status, 200); // B still has it });}✖ vulnerable: user A cannot read or delete user B's note (30.061037ms)✔ fixed: user A cannot read or delete user B's note (14.951679ms)ℹ tests 2ℹ pass 1ℹ fail 1 200 !== 404The vulnerable one fails with 200 !== 404: Alice got a 200 for Bob's note. The fixed one passes. (Timings vary on each run.) Put a test like this in CI for every endpoint that takes an ID.
"But my IDs are random UUIDs"
A common idea: use a1e5c6b8-eaa6-48a7-a95d-39b825efc3c9 instead of 2, so nobody can guess it. That helps a little. It stops easy counting. But OWASP is clear: complex IDs are defence in depth, and access control checks are still essential. IDs leak. They appear in emails, logs, browser history, shared links, screenshots, and API list responses. If someone gets one, the question "may this user see it?" must still be answered by code. Treat unguessable IDs as a bonus, never as the lock.
Deny by default
What happens when a developer adds a new route and forgets to write a permission rule? In a "default allow" design the route is open to everyone. In a "default deny" design the route is closed until someone writes a rule. The OWASP Authorization cheat sheet says the app must always make a decision and should deny when no rule matches. It also says you should be able to explain why each permission was granted.
A small version with a rule table:
const rules = [ { method: "GET", path: /^\/notes\/\d+$/, allow: (u) => !!u }, { method: "DELETE", path: /^\/users\/\d+$/, allow: (u) => u?.role === "admin" },];const check = (u, method, path) => rules.find((r) => r.method === method && r.path.test(path))?.allow(u) ?? false;Run with three kinds of users and one route nobody wrote a rule for:
alice GET /notes/1 truealice DELETE /users/2 falseadmin DELETE /users/2 trueadmin GET /secret-new false (no rule written yet, so denied)Look at the last line. Even the admin is denied on /secret-new, because nobody decided who may use it. That is annoying for a minute and safe forever. The ?? false at the end of check is the whole idea: no match means no.
Two related rules from the same cheat sheet:
- Check on every request, on the server, whether the call came from your own page, a script, or
curl. Hiding the Admin button in the React UI is not security. The URL still works if someone types it. That typing of hidden URLs is called forced browsing. - Put the check in one central place (middleware, a guard, a policy function), not copy it into each handler. The more places it lives, the more likely one is forgotten.
- Static files count too. If
/invoices/2024-123.pdfis private, the file route needs a check as well.
Roles versus ownership
There are two different questions people mix up:
| Question | Example | Check |
|---|---|---|
| Role check: what kind of user is this? | "Only admins may delete users." | user.role === "admin" |
| Ownership check: does this thing belong to this user? | "You may edit your own note." | note.ownerId === user.id |
Most real rules combine them: "the owner or a support agent may read it; only the owner may delete it." A role check alone is not enough for per-user data. If you only check role === "user", every user can reach every user's data, which is exactly our IDOR. An ownership check alone is not enough either, when staff need to help customers.
OWASP's cheat sheet says to prefer attribute-based (ABAC) or relationship-based (ReBAC) access control over pure roles for application data, because roles alone get tangled ("role explosion"), do not model "this user and this record" well, and fit multi-tenant apps badly. In plain words: write rules about the relationship between the person and the object, like note.ownerId === user.id or user.teamId === project.teamId, and keep roles for the broad powers.
Also follow least privilege: give a new account the lowest role, and give your own database user and API keys only what they need. It is easier to grant more later than to take back what people got used to.
Never trust a role or an ID that came from the client. If the request carries "role": "admin" or "isAdmin": true or a userId header, treat it as a claim from a stranger. Read the user and role from your session or a verified token.
Mass assignment
Here is another way an attacker gets powers they should not have. Many web frameworks and a lot of hand-written code take the JSON body and copy it onto a record. The OWASP Mass Assignment cheat sheet says this can let attackers create or overwrite fields the developer never meant to expose. It lists other names for it: mass assignment (Rails, Node), autobinding (Spring, ASP.NET), object injection (PHP).
Our profile endpoint lets a user change their name and email. This is the vulnerable version, a single line:
if (req.method === "PATCH" && req.url === "/me") { const body = JSON.parse(raw || "{}"); Object.assign(me, body); // copies EVERY field the client sent}The normal request is {"name":"Alice B."}. The attacker sends extra fields they guessed from the response (the app returns the whole user object, so they can see that role and plan exist):
MODE = vulnerable200 {"id":1,"email":"alice@example.com","name":"Alice B.","role":"admin","plan":"enterprise"}Alice made herself an admin and gave herself the enterprise plan, with one request and no hacking tool. The server did exactly what the code said.
The fix is an allow-list: name the fields a user may set, and copy only those.
const EDITABLE = ["name", "email"];for (const k of EDITABLE) if (typeof body[k] === "string") me[k] = body[k].trim().slice(0, 100);MODE = fixed200 {"id":1,"email":"alice@example.com","name":"Alice B.","role":"user","plan":"free"}The name changed. The role and plan stayed. The extra fields were ignored.
What OWASP recommends, in plain words:
- Use a separate input shape (a DTO, "data transfer object", or a Zod schema) that contains only the fields the caller may edit. Validate it (lesson 1 and Backend Lesson 3). Do not bind the request straight onto a database model.
- Allow-list the fields. Do not rely on a block-list (
delete body.role). A block-list only covers what you remembered. The day someone adds aisStaffcolumn, it is open. The cheat sheet says a block-list should not be the only protection. - A DTO or allow-list controls which fields may be written. It does not replace the ownership check on the record. You need both: "may Alice edit this record?" and "which fields may she edit?"
- Do not return fields the client has no need to see. It shows attackers which names to try.
Watch for spread and merge helpers: { ...user, ...req.body }, Object.assign(user, req.body), ORMs with a "create from object" shortcut, and "update" endpoints that pass req.body into the query. All copy every key the client sends.
401, 403 or 404?
Three status codes matter here. See Browser Lesson 6 for the full HTTP table.
- 401 Unauthorized really means "not authenticated": no valid login. The name is misleading.
- 403 Forbidden means "I know who you are and the answer is no."
- 404 Not Found means "there is nothing here" (or "I will say there is nothing here").
Which one when Alice asks for Bob's note? I ran both ways:
MODE = fixed403alice GET /notes/2 -> 403 {"error":"forbidden"}alice GET /notes/3 -> 404 {"error":"not found"}MODE = fixed404alice GET /notes/2 -> 404 {"error":"not found"}alice GET /notes/3 -> 404 {"error":"not found"}With 403, the two answers differ. Alice learns that note 2 exists and note 3 does not. For notes that is a small leak. For other data, like "does an account with this email exist" or "is there a secret project called X", the leak is real, and it helps attackers map what exists (enumeration, as in lesson 3).
My practical rule:
- If the resource's existence is private, return 404 for "not yours" and "not there". Both look the same.
- If existence is public (everyone knows the project exists, they just can't open it) or the message helps real users, 403 is clearer and it is fine.
- Use 401 only for "log in first".
- Whatever you choose, be consistent, and log the denied attempts on the server so you can see someone scanning IDs.
Where access control bugs hide
A short checklist of places that get missed:
- Other methods on the same path.
GET /notes/2is checked butPUTorDELETEis not. - Bulk and export endpoints.
GET /notes?ids=1,2,3or/export.csvreturns more than the single-item check protects. - The related object. You check the note but not the
projectIdin the body that says where to attach it. - Admin routes behind a hidden URL. Not linked is not protected.
- Search, list and report screens. They may list other users' items even if the detail page is protected.
- Background jobs and webhooks that run with a system identity and trust IDs in the payload.
- Multi-tenant apps. Every query needs the tenant or organization ID, not only the user ID.
Try it
- Add a
PUT /notes/:idroute to the fixed server. First write it without the owner check and prove with the two-user test that it is vulnerable. Then fix it. - Make the 403 version and 404 version of "not yours". Which one reveals which IDs exist? Write one sentence on which you would choose for a medical app.
- Add
POST /methat merges a body withObject.assign. Send{"role":"admin"}. Then fix it with an allow-list. - Add a block-list fix (
delete body.role) first, and then send{"plan":"enterprise"}. What happens? - In the rule table, add one rule for
GET /reports/:idthat allows only users whoseteamIdequals the report'steamId. - Try a non-numeric ID such as
/notes/abcand/notes/2%20against the server. Does the route still do something safe?
Answers
- Without the check, Alice's
PUT /notes/2succeeds (a 200) and the test fails; withnote.ownerId === user.idit returns 404 and the test passes. - The 403 version tells Alice that note 2 exists and 3 does not. For a medical app I would use 404 so IDs do not confirm that anything exists.
- After the merge, the response shows
"role":"admin". The allow-list version ignores the field. - The block-list stops
role, butplanstill gets through. A block-list only covers fields you remembered. - The rule gets the report and compares
user.teamId === report.teamId. The rule needs the object, which is why access rules can't be only about the URL. /notes/abcdoes not match the\d+pattern, so the server returns the "no such route" 404. The strict pattern is a small safe habit.
Interview questions
What is an IDOR? Give an example and the fix. (Must know)
An insecure direct object reference is when the app takes an ID from the request, like /orders/123, and returns the object without checking that the caller may have it. If I change 123 to 124 and see someone else's order, that's an IDOR. The fix is an object-level authorization check on the server: confirm the order belongs to the logged-in user, or put the owner into the lookup (WHERE id = ? AND user_id = ?) with the user taken from the session. I test it with two users. API people call it BOLA.
Authentication versus authorization? (Must know)
Authentication proves who you are. Authorization decides what you may do. A request can be perfectly authenticated and still be forbidden. Broken access control is mostly a failure of authorization, and the IDOR example shows it: the login passed, the permission check never happened.
Do random UUIDs fix IDOR? (Trap)
No. They make guessing hard, which is good defence in depth, but IDs leak through links, logs, emails, referrer headers and list endpoints. If the server does not check access, anyone holding a UUID can use it. The OWASP IDOR cheat sheet says the same: complex identifiers help, but access control checks are still essential.
What does "deny by default" mean and why does it matter? (Must know)
If no rule explicitly allows a request, it is refused. When a developer adds a new route and forgets the rule, the route is closed, not open. It turns a forgotten check from a breach into a bug report. In code it is the ?? false at the end of a permission lookup, or a global guard that rejects any route without an annotation.
Role-based or ownership-based checks? (Senior signal)
Both, for different questions. Roles answer "what kind of user is this", like admin versus user. Ownership answers "does this record belong to this user". A role check alone lets every ordinary user reach every record. Real rules combine them, such as "owner or support can read, only owner can delete". For richer cases, OWASP prefers attribute or relationship based models, which handle teams and tenants without an explosion of roles.
What is mass assignment and how do you stop it? (Must know)
It's when the server copies request fields onto a record without choosing which fields. An attacker adds "role":"admin" to a profile update and becomes an admin. I stop it with an allow-list of editable fields, or a separate input type (DTO or schema) that contains only those fields. I do not rely on a block-list, because a new sensitive field would be open by default. I also keep the ownership check, since field control does not decide who may edit the record.
403 or 404 when a user asks for something that isn't theirs? (Senior signal)
It depends on whether the existence of the thing is private. If it is, I return 404 for both "not yours" and "does not exist" so an attacker can't map valid IDs. I tested that a 403 reveals which IDs exist. If the existence is public and a clear message helps the user, 403 is fine. I use 401 only for "not logged in", and I log denied attempts either way.
Is hiding the admin button in the UI enough? (Trap)
No. The browser is controlled by the user. Anyone can call the API directly with curl or edit requests. Every sensitive action must be checked on the server on every request. The UI hiding is only for convenience. It also shouldn't be the only place where role names are known.
How do you test for broken access control? (Senior signal)
Make two users (and an admin), have each create objects, then log in as one and try to reach the other's objects, for every method: read, create, update, delete, export. Check that all return 403 or 404 as designed, even when the ID is unguessable. I automate it in CI so a new endpoint without a check shows up as a failing test. I also try routes with no rule, to confirm deny by default.
Where would you put authorization checks in an application? (Senior signal)
In one central layer on the server, such as middleware, guards or a policy module, so that every route goes through it and none relies on a developer remembering. Object-level checks go close to the data lookup (scoped queries), because the object is needed for the decision. The front end never makes the decision. Rules are in code that is tested, and unknown routes are denied.
You find a user can change isAdmin in the profile update. What do you do first? (Trap)
First I check whether it was exploited: look at logs and the data for users whose role changed. I fix the endpoint with an allow-list, deploy, then review all other places that bind request bodies to models, since the same pattern is usually copied. I add a test that sends extra fields and checks they are ignored. I also reset any wrongly granted roles and review other sessions. I'd record it as a lesson for code review (lesson 8).
Key takeaways
- Authentication is not authorization. Logging in does not mean "may see everything".
- IDOR is the top real-world bug: an ID in the request, no check of the owner. Fix by checking ownership on the server, and put the owner into the lookup using the session user.
- Random IDs do not replace access checks.
- Deny by default. Check on every request, in one central place, for every method.
- Use role checks for broad powers and ownership/relationship checks for per-user data. Apply least privilege.
- Mass assignment: allow-list the fields clients may write, and never trust role or ID fields from the client.
- Choose 404 when existence is private, 403 when it isn't, 401 for not logged in.
- Test with two users in CI.
What's next
Lesson 5 is about secrets, encryption and data: where API keys leak (git history, logs, front-end bundles), the difference between hashing, encoding, signing and encryption, safe randomness, AES-GCM, TLS in plain words, and how to handle personal data. Practise now: pick one endpoint in a project of yours that takes an ID, and write the two-user test for it.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Authentication and Authorization on the Server · Backend in Depth
- Securing LLM Applications: The OWASP Top 10 for LLM Apps in Code · AI Engineering
- Prompt Injection and Safe Tool Use for Agents · AI Engineering
Part of the study guide
- Web Security Guide for Developers: OWASP Top 10 Basics
- API Design Guide: REST, Errors, Pagination and Versioning
- Next.js Guide: App Router, Server Components and Deploying
Related series