Your API Has Roles. That Does Not Mean Access Control Works
A practical test plan for object-level authorization, tenant isolation, and API access control bugs that survive happy-path role checks.
Your API Has Roles. That Does Not Mean Access Control Works#
Most API access control bugs are not caused by missing login. They happen after login, when the backend forgets to ask a more specific question: should this user perform this action on this object right now?
Roles help, but they are not the whole model. A user can be admin in one workspace, a viewer in another, removed from a project yesterday, and still hold a token issued last week. That is where neat RBAC diagrams start leaking data.
Test the object, not just the role#
The weak version of authorization checks only the role:
if (session.user.role !== 'admin') { throw new Error('Forbidden') }
The stronger version checks role, object ownership, tenant membership, and action:
const report = await db.report.findFirst({ where: { id: reportId, tenantId: session.tenantId, project: { memberships: { some: { userId: session.user.id, role: { in: ['owner', 'analyst'] }, }, }, }, }, }) if (!report) { throw new Error('Not found') }
This is not just cleaner code. It changes the failure mode. Unauthorized objects and nonexistent objects now look the same from the outside, which reduces enumeration and makes the contract easier to test.
Build a matrix before touching Burp#
Random request tampering finds bugs, but a matrix finds classes of bugs. Start with the relationships your product actually has.
| Case | User A | User B | Expected result | | --- | --- | --- | --- | | Same tenant, lower role | Owner | Viewer | Viewer cannot write | | Different tenant | Owner in tenant 1 | Owner in tenant 2 | No cross-tenant read | | Removed member | Former member | Active member | Old token loses access | | Shared object | Creator | Invited viewer | Viewer gets only allowed fields | | Soft-deleted object | Owner | Owner | Detail, list, export agree | | Internal endpoint | Support user | Customer user | Customer cannot call support action |
Run this matrix against list, detail, update, delete, export, and search endpoints. The boring endpoints matter. Exports and search are where polished products often leak old assumptions.
Watch for authorization hidden inside the UI#
Frontend checks are useful for user experience. They are useless as an API boundary.
If the frontend hides the "delete project" button for viewers, still send the request as a viewer. If the UI only shows the first 20 invoices, still request invoice 21 directly. If the admin panel uses a separate route, check whether the API route is actually separate or just hidden behind a different navigation item.
The backend should survive a client that ignores every UI decision.
Tenant filters belong in the query#
Pulling an object and checking ownership afterwards is easy to get wrong:
const file = await db.file.findUnique({ where: { id: fileId } }) if (file.tenantId !== session.tenantId) { throw new Error('Forbidden') }
This can still leak timing differences, object existence, or fields returned by logging and error handling. Prefer making authorization part of the data access:
const file = await db.file.findFirst({ where: { id: fileId, tenantId: session.tenantId, }, })
When the query returns nothing, the rest of the app has less sensitive information to accidentally expose.
What to log when a check fails#
Do not log raw secrets or full request bodies. Do log enough to debug the authorization decision.
Good fields:
userIdtenantIdresourceTyperesourceIdactionpolicyNamedecisionrequestId
This makes access control testable in production without turning logs into a second database of sensitive data.
Closing checklist#
Before calling an API authorization fix done, answer these:
- Does the query include tenant or ownership constraints?
- Does the policy distinguish read, write, export, and delete?
- Do removed users lose access before token expiry?
- Do list, detail, search, and export use the same policy?
- Do unauthorized and nonexistent resources produce the same external behavior?
- Is there a regression test for the relationship that failed?
Access control is not a middleware checkbox. It is a product model encoded in code. If the model is fuzzy, the API will be fuzzy too.
What do you think?
React to show your appreciation