Multi-tenant access control: check the record, not just the role
A user can be allowed to view invoices and still be barred from one particular invoice. That check belongs on the server, for every route that touches the record.
By Peak Security ยท
An invoice belongs to someone
Say a user at organization A can view invoices. They sign in, open one of their invoices, and get the expected response. Now they ask for an invoice owned by organization B. Their role has not changed, but the answer should. The server needs to check who owns that invoice before returning it.
Hiding the button does not help if the API accepts a request for another record. Neither does a hard-to-guess ID: an ID can leak through a link, log, or response. The decision has to use the caller, the record, and the action they want to take. OWASP calls a missing check here broken object-level authorization.
Follow the record through the product
An invoice may appear in a page, an export, and an API response. A permission check on the page does not tell you whether the export route checks the same thing. Updates and deletes need their own checks too. If the product lets users switch organizations, the server also has to establish which organization the request is acting for.
To test this safely, set up two test organizations and create a record in one of them. Use an account in the other organization to try each relevant read and write action. Synthetic records make the result easy to interpret without touching a customer invoice.
Make the check hard to skip
- Get the caller's identity and organization membership from trusted server-side data.
- Check the requested action against the specific record on each protected request.
- Cover exports and background jobs that read or change records for a user.
- Keep a test where an account in one organization tries to reach another organization's record and is denied.
OWASP's authorization guidance recommends denying by default and validating permissions on every request. Our API testing page explains how to scope a check like this.