1. Courses
  2. Cybersecurity
  3. Application Security for Developers
  4. Stop 1

35 minIntermediate

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?

answers "who are you?" 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 .
  • 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 , 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?

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:

TypeScript
// 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 (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.

Before: id only After: id + shop Shop 41 app Shop 41 app asks for 9042 asks for 9042 API query WHERE id=9042 any shop API query WHERE id=9042 AND shop_id=41 row found: shop 88's no row returned nothing to send 200 OK data leaked 404 Not Found denial logged
The same request with and without the ownership check. On the left the query asks only for the id, so shop 41 receives shop 88's invoice. On the right the caller's shop, taken from the session, is part of the query, so the database finds nothing and the API returns 404 and logs the denial.

Quick check

The agency proposes switching invoice ids from sequential numbers to random UUIDs, and closing the finding. What do you say?

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 shopId in the request is, at most, a filter that must be checked against what the caller may see.
  • Scope every query by tenant. A 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 (RLS) can filter rows by the shop set for the current transaction, so a query that forgets its WHERE clause still returns only that shop's rows.
TypeScript
@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
}

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

their shops other shops credit limit finance admin sales rep owner or staff unknown role horizontal other tenant vertical more power higher row, more power
The Partner app's access matrix for invoices and credit limits. "Their shops" means the caller's own shop, or the shops on a sales rep's list. A sideways move into another shop's column is horizontal escalation; reaching a higher row's action is vertical. Unknown roles get nothing.

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?

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

  1. 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.
  2. Add a global guard that closes undeclared routes

    A guard registered as APP_GUARD runs on every route. Routes declare their function-level rule with a decorator; a route without one is refused. When canActivate returns false, NestJS answers 403.

    TypeScript
    export 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 }
  3. 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.

    TypeScript
    listInvoices(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))],
      );
    }
  4. Allowlist writable fields

    A DTO names what a caller may change. With whitelist on, other properties are stripped; with forbidNonWhitelisted the request is rejected with 400, which also shows up in your logs.

    TypeScript
    export 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 }));
  5. 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.

    SQL
    ALTER 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);
  6. 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.

    TypeScript
    it('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);
    });
  7. 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 shopId or userId read 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.

JavaScript exerciseRuns in your browser with tests
// 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;
}
Ctrl + Enter runs
Show a solution
JavaScript
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.

Pass the exercise to finish this task.

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.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Accounts with 5 or more denied invoice reads (403 or 404)
SELECT user_id, count(*) AS denied
FROM invoice_access
GROUP BY user_id;
Ctrl + Enter runs
Show a solution
SQL
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.

Pass the exercise to finish this task.

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.

TypeScript
@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 shopId from 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 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 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?

Flashcards

Open my review

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

Episode transcriptOne digit away from someone else's invoice · about 12 minutes · tap any line to play from there
  1. NadiaImran, you own the Partner app. What would you say if a shop owner rang support and said, I changed one digit in my invoice link and saw another shop's invoice?
  2. ImranI'd say that can't happen. You have to log in to see invoices.
  3. NadiaThat's the answer almost every team gives. And it's the reason broken access control has sat at the top of the OWASP Top 10 since twenty twenty-one.
  4. ImranSo logging in isn't enough. Then what's missing?
  5. NadiaA second question. Logging in answers who you are. It doesn't answer whether this particular invoice is yours. Today is about that second question.
  6. NadiaPicture a hotel. At the front desk you show your passport and get a key card. That's the hotel checking who you are.
  7. ImranAnd the key card opens my room.
  8. NadiaOnly because the lock on each door asks whether this card was issued for this room. Now imagine a hotel where every lock only asks, is this a valid card for this hotel?
  9. ImranThen every guest opens every room. And nobody broke in. Everyone checked in properly.
  10. NadiaExactly. The hotel checked identity once and forgot to check permission at each door. That's what most of these bugs look like in code.
  11. ImranGive me the vocabulary, then.
  12. NadiaAuthentication is who are you. Authorisation is, are you allowed to do this, to this thing. Frameworks help a lot with the first. Your business rules decide the second, so no framework can write it for you.
  13. NadiaEvery request that touches data needs three answers. Who is calling, taken from a verified session. May this kind of caller use this function at all. And may this caller touch this particular object.
  14. ImranSo a shop owner may view invoices in general. That's the function check. But invoice nine thousand and forty-two belongs to shop eighty-eight, and she's shop forty-one. That's the object check.
  15. NadiaRight. And the object check is the one that goes missing. Each endpoint needs its own, so one forgotten line opens one door. Scanners can't tell whose data is whose. And most teams test with one account, so the bug never shows.
  16. ImranWhat did OWASP actually say about it?
  17. NadiaIn the twenty twenty-one list it moved up from fifth place to first, with more occurrences than any other category in their data. The twenty twenty-five edition keeps it first, and folds server-side request forgery into it.
  18. NadiaIf you can see your screen, the first diagram has two columns. If not, picture the same request travelling down two paths side by side.
  19. NadiaOn the left, shop forty-one's app asks for invoice nine oh four two. The API's query asks only for that id, from any shop. The database finds shop eighty-eight's row, and the API sends it back with a two hundred OK. Data leaked.
  20. ImranAnd on the right?
  21. NadiaSame request. But the query says, this id, and shop forty-one, taken from the session. The database finds no row. The API answers four oh four, not found, and writes a log line saying it denied the request.
  22. ImranWhy four oh four and not four oh three, forbidden? Isn't forbidden more honest?
  23. NadiaBoth are fine. Four oh four has one advantage. Someone probing can't even confirm that invoice nine oh four three exists. Pick one and use it everywhere.
  24. ImranThis has a name, doesn't it? I've seen IDOR in bug reports.
  25. NadiaInsecure direct object reference. The server loads an object using an id the caller supplied, and never checks the caller may have it. The caller just changes a number in the link, the query string or the request body.
  26. ImranThen the easy fix is random ids. Long ones nobody can guess. Our agency suggested exactly that.
  27. NadiaThat's the reasonable wrong answer. Random ids slow down someone stepping through numbers, so they're a fine extra layer. But an id is a name, not a permission.
  28. NadiaShop owners forward invoice links to their accountants. Support agents paste them into tickets. Once an id leaks, it works for anyone who's logged in. The server still has to check ownership.
  29. ImranWhich is what happened in the lesson's story. Ravi mistyped one digit sharing his invoice link and got another shop's invoice. Name, address, order lines, credit balance.
  30. NadiaAnd he rang support that afternoon. The hotfix shipped the same night. The query got the caller's shop, and a mismatch returns four oh four.
  31. ImranBut the interesting part was the logs, right?
  32. NadiaYes. About a hundred and twenty thousand invoice reads in ninety days. Twenty-three where the invoice's shop wasn't the caller's shop. Three accounts behind them.
  33. ImranRavi's typo, an agency tester, and one shop account that read nineteen invoices with consecutive numbers in four minutes.
  34. NadiaThat third one is treated as a data incident. The account is suspended pending review and the nineteen shops are told. And now an alert fires when one account is denied on more than ten invoice reads in an hour.
  35. ImranOkay. How do we fix it so it stays fixed? I don't want this back in six months on a different screen.
  36. NadiaThen don't fix it with a one-off if statement in each controller. Fix the pattern. Deny by default. A route with no declared rule is closed. A role the policy doesn't know gets nothing. A missing shop id is a deny, never a match.
  37. NadiaTake the caller's identity and shop from the session, never from the request. Scope every list query by shop. And write the decision once, in a policy function like can read invoice, tested hard and called everywhere.
  38. ImranThen a reviewer checks one function instead of forty controllers.
  39. NadiaExactly. And under it, a second layer in the database. Postgres row-level security filters rows by the shop set for the current transaction, so a query that forgets its filter still only sees that shop's rows.
  40. ImranI sense a catch.
  41. NadiaTwo. The role that owns the table skips its policies unless you force them, and many apps connect as the owner. And if you set the shop for the whole connection instead of the transaction, a pooled connection carries the last request's shop into the next one.
  42. ImranSo one shop could suddenly see another shop's rows, through the safety net itself.
  43. NadiaThat's why the lesson sets it transaction-locally. When the transaction ends, the setting ends.
  44. ImranYou said function checks go missing too. What does that look like?
  45. NadiaThe back office has a route to change a shop's credit limit. The button only shows for finance staff, so nobody added a role check on the server. But hiding a button changes the screen. Anyone who can send a request can call the route.
  46. ImranAnd mass assignment? That one always sounds abstract.
  47. NadiaA profile update copies the whole request body into the database update. The form only sends a name and a phone number. But the caller can add a credit limit field, or a role field, and the server saves it.
  48. ImranSo the fix is a list of fields we accept.
  49. NadiaAn allowlist. In NestJS, a DTO that names the writable fields, and the validation pipe set to strip or reject anything else.
  50. NadiaThe second diagram is a matrix. Rows are roles, from finance admin at the top down to unknown role at the bottom. Columns are their shops, other shops, and the credit limit.
  51. ImranFinance admin gets ticks everywhere. Sales reps and shop users get a tick only for their own shops. Unknown role gets crosses all the way along.
  52. NadiaAnd two arrows. Sliding sideways into another shop's column is horizontal escalation, same role, someone else's data. Climbing to a higher row's action is vertical, like a shop owner raising their own credit limit.
  53. ImranAnd writing that matrix down first gives you the policy function and the test plan at the same time.
  54. NadiaRight, and that's the first step of the Build it. Write the matrix. Then add a global guard in NestJS, so every route must declare a rule or it's refused with a four oh three.
  55. NadiaThen scope list queries from the session user. Add a DTO allowlist for writes. Turn on row-level security with force, set per transaction. Add tests with two shops. And log every denial with both shop ids.
  56. ImranBoth shop ids. The caller's and the invoice's.
  57. NadiaYes, because a successful read where those two differ, outside finance and a rep's list, is a direct sign a check is missing somewhere.
  58. ImranLet's talk tests. All our end-to-end tests log in as one seeded shop owner.
  59. NadiaSo they can never see this bug. It only exists between two tenants. Seed two shops, each with an owner and staff, a sales rep for one of them, and a finance admin. Then for every route that takes an id, have one shop ask for the other's object.
  60. ImranAnd expect a four oh four.
  61. NadiaAnd run it in CI, so a new route without a rule fails the build.
  62. NadiaHere's one to think about. In one evening's log, account twelve is denied seven times on seven different invoice ids in three minutes. Account fifteen is denied five times on the same invoice over twenty minutes. Which one worries you? Take a second.
  63. ImranTwelve. Seven different ids in a row looks like someone stepping through numbers. Fifteen keeps hitting one invoice. That sounds like a broken link or an old bookmark.
  64. NadiaExactly. Fifteen is a bug to fix. Twelve goes to the security team. The practice section has you write that query yourself.
  65. ImranQuestions for my next review with the agency, then. Where does this route get the shop, from the session or from the request? Which policy function decides, and where are its tests? And what happens for a role the policy has never heard of?
  66. NadiaI'd add one. If someone stepped through our invoice ids tonight, which alert would fire, and who would see it?
  67. ImranMy summary. One, logging in isn't permission. Every request needs a function check and an object check, on the server. Two, take the shop from the session and scope every query by it. Three, random ids help, but they're not the fix.
  68. NadiaGood. I'd add deny by default. Unknown roles, missing fields and routes without a rule get nothing. And test with two tenants, or you're only testing the happy path.
  69. ImranAnd when we fix the invoice route, we check the delivery notes and statements built the same month.
  70. NadiaAlways. Next time, injection. What happens when the text a shop types ends up being run as code, and the fixes that last.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Threat model checklist

STRIDE questions for one feature as data, with tests that every threat has an owner and a mitigation.

Open in the Security Lab
Next stopInjection and unsafe input
Was this lesson helpful?
Questions and discussion

Loading the discussion…