Broken access control
A logged-in partner shop changes one number in a link and reads another shop's invoice. That is broken access control, the most common serious flaw in web applications. This lesson shows how to spot it in a NestJS and PostgreSQL codebase, fix it so it stays fixed, prove the fix with tests and catch anyone who probes for it.
By the end you can
- Tell authentication from authorisation, and name the three checks every request needs
- Spot an insecure direct object reference in code review and fix it with an ownership check and a tenant-scoped query
- Write a deny-by-default policy function and a NestJS guard, and add Postgres row-level security as a second layer
- Recognise function-level gaps, mass assignment, and horizontal and vertical escalation
- Prove access rules with two-account tests and detect abuse in your access logs
The everyday picture
Think of a hotel. At the front desk you show your passport, and the clerk gives you a key card. That is the hotel checking who you are. But the passport check is not what keeps you out of other rooms. The lock on every door does that: it asks whether this card was issued for this room.
Now imagine a hotel where the door locks only ask "is this a valid key card for this hotel?" Every guest could open every room. Nobody broke in. Every guest was properly checked in. The hotel checked identity once and forgot to check permission at each door. Most broken access control looks exactly like that: the login works perfectly, and the door to each piece of data never asks whose it is.
Who are you, and what may you touch?
Authentication answers "who are you?" Authorisation answers "are you allowed to do this, to this thing?" Frameworks give you strong help with the first: a login library, a session or token check, a guard that rejects anonymous calls. The second depends on your business rules, so no framework can write it for you. That gap is where most of these bugs live.
Every request that touches data needs three answers, in this order:
- Who is calling? Identity comes from a verified session or token, never from something the client says about itself (you met session and token basics in Identity: passwords, MFA and passkeys; Authentication, sessions and tokens goes deeper).
- May this kind of caller use this function at all? A shop owner may view invoices; only finance staff may change a credit limit. This is function-level access control.
- May this caller touch this particular object? Invoice 9042 belongs to shop 88, so the owner of shop 41 may not read it. This is object-level access control, and it is the one most often missing.
The OWASP Top 10 is a list, updated every few years from data on real applications, of the most important web application risks. In the 2021 edition, broken access control moved up from fifth place to first, and it had more occurrences than any other category in OWASP's data. The 2025 edition keeps it first, and folds in server-side request forgery, which had its own place in 2021. The reasons are structural. Each endpoint needs its own object check, so one forgotten line opens one door. Automated scanners can't tell which data belongs to whom. And most teams test with one account, so the bug never shows up in testing.
Quick check
A shop owner logs in with the right password and a passkey, then reads a different shop's invoice by editing the link. Which control failed?
She proved who she is, and shop owners are allowed to view invoices, so the first two checks did their jobs. What was missing is the per-object question: is this invoice hers? Any value in a link, form or request body can be changed by the caller, so the server must check it.
Insecure direct object references in the Partner app
The course's practice copy of the Partner app has an invoice endpoint. A shop owner taps "Download invoice" and the app calls GET /partner/invoices/9042. The controller loads the invoice by its id and returns it. Here is the vulnerable pattern, in a few lines:
// BEFORE: the route is behind login, but any logged-in shop gets any invoice
@UseGuards(AuthGuard)
@Get(':id')
async getInvoice(@Param('id', ParseIntPipe) id: number) {
const { rows } = await this.db.query('SELECT * FROM invoice WHERE id = $1', [id]);
return rows[0];
}This is an insecure direct object reference (IDOR). The input class is simple: a valid, logged-in user changes an identifier in the path, query string or body to one that belongs to someone else. With sequential ids, the neighbouring numbers are other shops' invoices. MITRE's weakness list calls it CWE-639, authorisation bypass through a user-controlled key.
From the reviewer's side, look for these shapes in a pull request:
- A route parameter or body field with "id" in its name that goes straight into a query, with nothing in that query or after it that mentions the caller.
- A tenant identifier, such as
shopId, read from the request (path, query string, body or a header) instead of from the verified session. - A guard that checks only that the user is logged in, on a route that returns one customer's data.
- List and export endpoints. They often get a filter by tenant, while the single-item and download routes next to them don't.
From the fixer's side, the fix is to make the query itself answer the ownership question. Take the shop from the session, add it to the WHERE clause, and return the same 404 for "not yours" as for "does not exist". Returning 404 instead of 403 here means a prober can't even confirm that invoice 9043 exists. Both are acceptable; pick one and use it everywhere.
Quick check
The agency proposes switching invoice ids from sequential numbers to random UUIDs, and closing the finding. What do you say?
Unguessable ids slow down someone stepping through numbers, so they are a reasonable extra layer. But an id is a name, not a permission. Ravi forwards his invoice link to his accountant, a support agent pastes one into a ticket, and any of those ids then works for anyone logged in. Encrypting the id changes its look, not who may open it.
The fix pattern: deny by default, check every object on the server
One-off if statements in each controller are how this bug comes back six months later. The fix that lasts is a pattern the whole codebase follows:
- Deny by default. A route with no declared access rule is refused, not open. A role the policy doesn't know gets nothing. A missing field (no shop id on the user, no shop id on the record) is a deny, never a match.
- Identity and tenant come from the server. The caller's user id, role and shop come from the verified session or token. A
shopIdin the request is, at most, a filter that must be checked against what the caller may see. - Scope every query by tenant. A tenant is the customer whose data must stay separate, here one partner shop. List queries carry
WHERE shop_id = $caller; single-object reads either do the same or load the object and pass it through a policy function before returning it. - Central policy functions. Write
canReadInvoice(user, invoice)once, test it hard, and call it everywhere. A reviewer then checks one function instead of forty controllers. - A second layer in the database. PostgreSQL row-level security (RLS) can filter rows by the shop set for the current transaction, so a query that forgets its
WHEREclause still returns only that shop's rows.
@UseGuards(AuthGuard)
@Get(':id')
async getInvoice(@Param('id', ParseIntPipe) id: number) {
const { rows } = await this.db.query('SELECT * FROM invoice WHERE id = $1', [id]);
return rows[0]; // anyone logged in gets any invoice
}@Policy('invoice:read') // function level: which roles may call this
@Get(':id')
async getInvoice(@Param('id', ParseIntPipe) id: number, @Req() req: AuthedRequest) {
const invoice = await this.invoices.findById(id);
// object level: one tested policy function, caller taken from the session
if (!invoice || !canReadInvoice(req.user, invoice)) {
this.audit.denied(req.user, 'invoice:read', id); // feeds detection
throw new NotFoundException(); // same answer as "no such invoice"
}
return invoice;
}Row-level security deserves a warning. By default, the role that owns a table and any role with the bypass attribute are not filtered by its policies, and many applications connect as the owner. Use FORCE ROW LEVEL SECURITY or connect as a separate, non-owner application role. And set the caller's shop per transaction, not per connection, or a pooled connection carries the last request's shop into the next one. The Build it section shows both.
Function-level checks, mass assignment and escalation
Object checks stop one shop reading another's data. Two other gaps are just as common.
Function-level gaps. The Partner app's back office has PATCH /admin/shops/:id/credit-limit. The button is only shown to finance staff, so nobody added a role check. But hiding a button is a user interface choice, not a control: anyone who can send an HTTP request can call the route. Every route needs a declared rule on the server, and admin routes should live behind a separate guard, ideally a separate path prefix that is easy to audit.
Mass assignment. A profile update does Object.assign(shop, body) or passes the whole request body to the ORM. The form only sends a display name and phone number, but the caller can add creditLimitCents or role to the body, and the server saves them. The fix is an allowlist: a DTO (data transfer object) that names the fields a caller may set, and NestJS's ValidationPipe set to strip or reject anything else. OWASP's Mass Assignment Cheat Sheet covers the same idea for other frameworks.
Both lead to privilege escalation, which comes in two directions. Horizontal escalation means the same role, someone else's data: shop 41 reading shop 88's invoices. Vertical escalation means a higher role's powers: a shop owner raising their own credit limit, which only finance may do. Write the rules down as a matrix before you write code. It becomes your policy function and your test plan.
Quick check
A shop staff member sends a profile update with an extra field, and her account's role changes to shop owner of the same shop. Which kind of flaw is this?
She gained a higher role's powers, which is vertical, and she did it by setting a field the form never offered, which is mass assignment. Staying inside her own shop makes it feel minor, but owners can usually manage staff and payments, so the impact is real.
Prove it with tests, and watch for abuse
Access control bugs survive because the happy path works. Every test logs in as one shop and reads that shop's data. The fix is to make two of everything part of the test setup: two shops, each with an owner and a staff account, a sales rep assigned to one of them, and a finance admin. Then turn the matrix into tests. For each route and role, assert the allowed case returns 200 and the forbidden case returns your chosen 404 or 403. Run them in CI, so a new route without a rule fails the build.
Unit-test the policy functions with the edge cases: missing shop id, an empty assigned list, an unknown role, a null record. Those are exactly the inputs where hand-written checks fail open, as in undefined == undefined being true. In manual testing, an intercepting proxy shows a tester every request the app makes, so they can repeat a request with the second account's session. You will use one in the sandbox in Attacking web applications.
Detection is the safety net for the bug you haven't found yet. Log every authorisation decision in a structured form: who (user id, role, shop), what (action, object id, the object's shop) and the result. Then watch for patterns that normal use doesn't make:
- One account requesting many different invoice ids in a short time, especially consecutive ones.
- A burst of 403 or 404 responses on object routes from one account or one IP address.
- Successful reads where the object's shop differs from the caller's shop, outside finance and the rep's list. If you log both shop ids, this is a direct sign the check is missing somewhere.
- Calls to admin routes from non-admin roles, even denied ones.
Many denials on the same id usually mean a broken link in the app, while denials across many different ids look like someone stepping through numbers. Writing detections turns patterns like these into alert rules.
Build it
Add deny-by-default access control to the practice Partner app's invoice API, with a database safety net, tests and a log you can alert on.
Write the matrix down
Before any code, agree the rules in a table everyone can review. Every role, every action, and "deny" for anything not listed.
Markdown| Action | owner or staff | sales_rep | finance_admin | any other role | |---------------------|----------------------|---------------------|---------------|----------------| | invoice:read | own shop | shops on their list | all shops | deny | | shop:setCreditLimit | deny | deny (may request) | all shops | deny | | shop:updateProfile | owner only, own shop | deny | deny | deny | Missing role, missing shop id or an action not in this table: deny.Add a global guard that closes undeclared routes
A guard registered as
APP_GUARDruns on every route. Routes declare their function-level rule with a decorator; a route without one is refused. WhencanActivatereturns false, NestJS answers 403.TypeScriptexport const POLICY_KEY = 'policy'; export const Policy = (name: string) => SetMetadata(POLICY_KEY, name); const ROUTE_RULES: Record<string, (u: SessionUser) => boolean> = { 'public': () => true, 'invoice:read': (u) => ['shop_owner', 'shop_staff', 'sales_rep', 'finance_admin'].includes(u.role), 'shop:setCreditLimit': (u) => u.role === 'finance_admin', }; @Injectable() export class PolicyGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(ctx: ExecutionContext): boolean { const name = this.reflector.getAllAndOverride<string>(POLICY_KEY, [ctx.getHandler(), ctx.getClass()]); const rule = name ? ROUTE_RULES[name] : undefined; if (!rule) return false; // no declared rule: closed if (name === 'public') return true; const user = ctx.switchToHttp().getRequest().user; // set by the auth guard from the session return Boolean(user) && rule(user); } } // in AppModule providers: { provide: APP_GUARD, useClass: PolicyGuard }Scope list queries by tenant
Lists are where a forgotten filter leaks the most rows at once. Build the scope from the session user, never from the query string.
TypeScriptlistInvoices(user: SessionUser) { if (user.role === 'finance_admin') { return this.db.query('SELECT * FROM invoice ORDER BY issued_on DESC LIMIT 100'); } const shopIds = user.role === 'sales_rep' ? user.assignedShopIds ?? [] : user.role === 'shop_owner' || user.role === 'shop_staff' ? [user.shopId] : []; // any other role: an empty list // an empty list matches nothing: deny by default return this.db.query( 'SELECT * FROM invoice WHERE shop_id = ANY($1::int[]) ORDER BY issued_on DESC LIMIT 100', [shopIds.filter((id) => Number.isInteger(id))], ); }Allowlist writable fields
A DTO names what a caller may change. With
whiteliston, other properties are stripped; withforbidNonWhitelistedthe request is rejected with 400, which also shows up in your logs.TypeScriptexport class UpdateShopProfileDto { @IsOptional() @IsString() @MaxLength(80) displayName?: string; @IsOptional() @IsString() @MaxLength(20) phone?: string; // no role, no creditLimitCents, no shopId: callers can't set them here } // main.ts app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));Add row-level security as the second layer
The shop-facing API connects as a role that does not own the table, and sets the caller's shop inside each transaction. If the setting is missing, the policy compares with NULL and matches no rows. Staff tools for reps and finance connect as a different database role with their own policies.
SQLALTER TABLE invoice ENABLE ROW LEVEL SECURITY; ALTER TABLE invoice FORCE ROW LEVEL SECURITY; -- applies to the table owner too CREATE POLICY invoice_shop_isolation ON invoice FOR SELECT USING (shop_id = NULLIF(current_setting('app.shop_id', true), '')::int); -- per request, inside the transaction (true = local to this transaction, -- so a pooled connection can't carry it into the next request) SELECT set_config('app.shop_id', '41', true);Test with two tenants
The end-to-end test that would have caught the original bug. Seed two shops, log in as each, and cross the streams.
TypeScriptit('shop 41 gets 404 for shop 88 invoice', async () => { await request(app.getHttpServer()) .get(`/partner/invoices/${shop88Invoice.id}`) .set('Authorization', `Bearer ${shop41OwnerToken}`) .expect(404); }); it('a route with no @Policy is refused', async () => { await request(app.getHttpServer()) .get('/partner/debug/ping') .set('Authorization', `Bearer ${financeAdminToken}`) .expect(403); });Log decisions in a shape you can query
One structured line per denial, with both shop ids. Never log invoice contents or tokens.
JSON{"at": "2026-10-14T21:04:11Z", "event": "authz.denied", "action": "invoice:read", "userId": 12, "role": "shop_owner", "callerShopId": 52, "objectId": 9104, "objectShopId": 88, "status": 404, "ip": "203.0.113.7"}
Bring it to your team
Ask these in your next code review or security review.
- Where does this route get the caller's shop: from the session, or from something the client sent?
- Which policy function or guard decides access here, and where are its tests?
- What happens for a role the policy has never heard of, or a user with no shop?
- Do we have a test where a second tenant asks for the first tenant's object, for every route that takes an id?
- Which fields can this endpoint write, and is that list explicit?
- If someone stepped through our invoice ids tonight, which alert would fire, and who would see it?
Red flags
- "The user can only reach that page through the menu."
- A
shopIdoruserIdread from the body, query string or a custom header and trusted. - Request bodies spread straight into ORM updates.
- Every test logs in as the same seeded account.
- Authorisation failures that are not logged, or logged without the object's owner.
Practice
Fix the invoice policy
Mangrove's finance team signed off these rules for reading invoices. Owners and staff of a shop (shop_owner, shop_staff) read only their own shop's invoices, using user.shopId. A sales_rep reads only invoices of shops in user.assignedShopIds. A finance_admin reads any invoice. Everything else is denied: an unknown or missing role, a missing user or invoice, a missing shop id on either side. The version below passed code review. Run the tests, find out why it shouldn't have, and fix it.
// user: { id, role, shopId, assignedShopIds }
// invoice: { id, shopId, totalCents }
function canReadInvoice(user, invoice) {
if (user.role === 'finance_admin') return true;
if (user.role === 'sales_rep') {
return user.assignedShopIds.includes(invoice.shopId);
}
return user.shopId == invoice.shopId;
}
test('owners and staff read only their own shop', () => {
expect(canReadInvoice({ role: 'shop_owner', shopId: 41 }, { id: 9001, shopId: 41 })).toBe(true);
expect(canReadInvoice({ role: 'shop_staff', shopId: 41 }, { id: 9042, shopId: 88 })).toBe(false);
});
test('a missing shop id on either side is a deny, not a match', () => {
expect(canReadInvoice({ role: 'shop_staff' }, { id: 9050 })).toBe(false);
expect(canReadInvoice({ role: 'shop_owner', shopId: 41 }, { id: 9051 })).toBe(false);
});
test('sales reps read only shops on their list, and no list means nothing', () => {
expect(canReadInvoice({ role: 'sales_rep', assignedShopIds: [41, 52] }, { id: 9003, shopId: 52 })).toBe(true);
expect(canReadInvoice({ role: 'sales_rep', assignedShopIds: [41, 52] }, { id: 9042, shopId: 88 })).toBe(false);
expect(canReadInvoice({ role: 'sales_rep' }, { id: 9003, shopId: 52 })).toBe(false);
});
test('finance admins read any real invoice, but not a missing one', () => {
expect(canReadInvoice({ role: 'finance_admin' }, { id: 9042, shopId: 88 })).toBe(true);
expect(canReadInvoice({ role: 'finance_admin' }, null)).toBe(false);
});
test('unknown roles, a missing role and a missing user are denied', () => {
expect(canReadInvoice({ role: 'driver', shopId: 41 }, { id: 9001, shopId: 41 })).toBe(false);
expect(canReadInvoice({ shopId: 41 }, { id: 9001, shopId: 41 })).toBe(false);
expect(canReadInvoice(null, { id: 9001, shopId: 41 })).toBe(false);
});
Show a solution
function canReadInvoice(user, invoice) {
if (!user || !invoice) return false;
const shopId = invoice.shopId;
if (shopId === undefined || shopId === null) return false;
switch (user.role) {
case 'finance_admin':
return true;
case 'sales_rep':
return Array.isArray(user.assignedShopIds) && user.assignedShopIds.includes(shopId);
case 'shop_owner':
case 'shop_staff':
return user.shopId !== undefined && user.shopId !== null && user.shopId === shopId;
default:
return false; // deny by default: unknown and missing roles get nothing
}
}The original had four holes: undefined == undefined is true, so a user and an invoice that both lacked a shop id matched; any role that wasn't finance or sales fell through to the shop comparison, so a "driver" with a shop id got in; a rep with no list crashed instead of being denied; and a null invoice was "readable" by finance. The switch with a default that returns false is the deny-by-default shape.
Find the account stepping through invoices
The table invoice_access is an extract of the Partner app's access log for one evening: who asked for which invoice, and the HTTP status. List every account with 5 or more denied invoice reads (status 403 or 404). Return user_id, denied (how many denied reads) and distinct_invoices (how many different invoice ids those denied reads asked for), with the highest distinct_invoices first. Then decide which account looks like probing and which looks like a broken link.
CREATE TABLE invoice_access (id int PRIMARY KEY, user_id int, caller_shop_id int, invoice_id int, status int, at timestamp); INSERT INTO invoice_access VALUES (1, 7, 41, 9001, 200, '2026-10-14 20:10'), (2, 7, 41, 9002, 200, '2026-10-14 20:11'), (3, 7, 41, 9030, 404, '2026-10-14 20:12'), (4, 7, 41, 9004, 500, '2026-10-14 20:15'), (5, 12, 52, 9050, 200, '2026-10-14 21:01'), (6, 12, 52, 9101, 404, '2026-10-14 21:02'), (7, 12, 52, 9102, 404, '2026-10-14 21:02'), (8, 12, 52, 9103, 404, '2026-10-14 21:03'), (9, 12, 52, 9104, 404, '2026-10-14 21:04'), (10, 12, 52, 9105, 404, '2026-10-14 21:04'), (11, 12, 52, 9106, 404, '2026-10-14 21:05'), (12, 12, 52, 9107, 403, '2026-10-14 21:05'), (13, 15, 60, 9200, 403, '2026-10-14 19:30'), (14, 15, 60, 9200, 403, '2026-10-14 19:31'), (15, 15, 60, 9200, 403, '2026-10-14 19:31'), (16, 15, 60, 9200, 403, '2026-10-14 19:40'), (17, 15, 60, 9200, 403, '2026-10-14 19:52'), (18, 20, 71, 9300, 404, '2026-10-14 22:00'), (19, 20, 71, 9301, 404, '2026-10-14 22:01'), (20, 20, 71, 9302, 404, '2026-10-14 22:03'), (21, 20, 71, 9303, 404, '2026-10-14 22:04'), (22, 20, 71, 9310, 200, '2026-10-14 22:05');
-- Accounts with 5 or more denied invoice reads (403 or 404)
SELECT user_id, count(*) AS denied
FROM invoice_access
GROUP BY user_id;
[{"user_id": 12, "denied": 7, "distinct_invoices": 7}, {"user_id": 15, "denied": 5, "distinct_invoices": 1}]
Show a solution
SELECT user_id,
count(*) AS denied,
count(DISTINCT invoice_id) AS distinct_invoices
FROM invoice_access
WHERE status IN (403, 404) -- a 500 is a server error, not a denial
GROUP BY user_id
HAVING count(*) >= 5
ORDER BY distinct_invoices DESC, user_id;Account 12 asked for seven consecutive invoice ids it doesn't own in about three minutes: that is the probing pattern, so it goes to the security team. Account 15 was denied five times on the same invoice over twenty minutes, which looks like a broken link or a stale bookmark: a bug to fix, not an attack. Account 20 has four denials across nearby ids, below this threshold but worth a look if it continues; a real rule would also add a time window.
Review a pull request
Lagoon Digital sends this controller for review. Find three access control problems, name each one (object level, function level or mass assignment, horizontal or vertical) and write the fix in one or two lines each.
@UseGuards(AuthGuard)
@Controller('partner')
export class ShopController {
@Get('orders')
list(@Query('shopId') shopId: string) {
return this.db.query('SELECT * FROM "order" WHERE shop_id = $1', [shopId]);
}
@Patch('profile')
update(@Req() req: AuthedRequest, @Body() body: any) {
return this.shops.update(req.user.shopId, { ...body });
}
@Post('admin/shops/:id/unblock') // only linked from the finance dashboard
unblock(@Param('id') id: string) {
return this.shops.unblock(Number(id));
}
}- You spot that the order list trusts
shopIdfrom the query string (object level, horizontal) and take the shop from the session instead - You spot that the profile update spreads the whole body (mass assignment, possibly vertical through fields like role or credit limit) and replace it with an allowlist DTO and a strict ValidationPipe
- You spot that the unblock route has no role check (function level, vertical) and give it a finance-only policy on the server
- Each fix comes with a test you would add
Show a model answer
1. Order list, object level, horizontal. Any logged-in shop can pass another shop's id. Drop the query parameter and use req.user.shopId, or for reps check the requested shop is on their list. Test: shop 41's token asking for shop 88's orders gets an empty list or 404.
2. Profile update, mass assignment. { ...body } lets a caller set creditLimitCents, role or blocked, which is vertical escalation. Use UpdateShopProfileDto with only display name and phone, and ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }). Test: a body with creditLimitCents gets 400 and the limit is unchanged.
3. Unblock, function level, vertical. Being linked only from the finance dashboard is not a control. Add @Policy('shop:unblock') with a finance-only rule, ideally in a separate admin controller behind its own guard. Test: a shop owner token gets 403.
Check yourself
The Partner app's end-to-end suite has 140 passing tests, all run as one seeded shop owner. A tester later finds that shop owners can read each other's delivery notes. Which change to the suite would most likely have caught it?
The bug only exists between two tenants, so a test with one tenant can never see it. Full coverage can run every line of a controller that has no ownership check and still pass. Invalid-input tests find a different class of bug, and testing on production data creates a privacy problem without adding a second caller.
The invoice list endpoint is GET /partner/invoices?shopId=41. It checks that the caller is logged in and returns the invoices for whatever shop id is in the query string. Sales reps use the same endpoint for their shops. What is the right fix?
The server must decide which shops the caller may see, from the session, and the request can only narrow that set. Removing the parameter fixes owners but breaks reps, who legitimately see several shops. Input validation still accepts 88, and hiding the picker changes the screen, not what the API returns.
The API sets the caller's shop for row-level security with a session-level SET app.shop_id on a pooled connection. Under load, a shop occasionally sees an empty invoice list, and once a shop saw another shop's invoices. What is the most likely cause and fix?
A pool hands the same connection to different requests, so a session-level setting from one request is still in place for the next one that forgets to set it. A transaction-local setting ends with the transaction. Turning RLS off removes the safety net instead of fixing how it is fed, and the pool size and type cast don't explain one shop seeing another's rows.
Flashcards
Key takeaways
- Logging in proves who you are; every request still needs a function-level check and an object-level check on the server.
- Take the caller's identity and tenant from the session, scope every query by tenant, and return the same 404 for "not yours" and "not there".
- Deny by default in one tested policy layer: unknown roles, missing fields and undeclared routes get nothing. Row-level security is a second layer, not the first.
- Mass assignment and hidden admin routes are the usual roads to vertical escalation; allowlist writable fields and guard every route.
- Test with two tenants for every route that takes an id, and log denials with both shop ids so probing shows up as an alert.
Sources and further reading
- A01:2021 Broken Access Control, OWASP Top 10, 2021
- OWASP Top 10 project page (2025 edition), OWASP Foundation
- Authorization Cheat Sheet, OWASP Cheat Sheet Series
- Insecure Direct Object Reference Prevention Cheat Sheet, OWASP Cheat Sheet Series
- Mass Assignment Cheat Sheet, OWASP Cheat Sheet Series
- CWE-639: Authorization Bypass Through User-Controlled Key, MITRE
- Row Security Policies, PostgreSQL documentation
- Guards, NestJS documentation