What it is#
Access control decides what an authenticated user may do. It breaks when the server trusts the client to only ask for things it's allowed to see.
- IDOR / BOLA: an endpoint like
GET /api/invoices/1043returns invoice 1043 to any logged-in user, so changing the number returns someone else's invoice. - Missing function-level checks: admin endpoints are hidden in the UI but not protected on the server.
- Privilege escalation through writable fields: a user can set
role,tenant_idoris_adminin a profile update (mass assignment). - Multi-tenant leaks: queries filter by record ID but not by the caller's organization.
Why it matters#
- Bulk exposure of other customers' personal or financial data, often by simply counting through IDs.
- Unauthorized changes: editing or deleting other users' records, approving your own requests.
- Vertical escalation to admin functions, and in multi-tenant SaaS, crossing from one customer to another.
- These bugs are easy to exploit, hard to see in logs because requests look normal, and are consistently the top category in the OWASP Top 10.
Medium when exposure is limited or non-sensitive. High to critical when it exposes regulated data at scale, crosses tenants, or grants administrative actions. Actual severity depends on context: who is affected, what data or actions are reachable, and existing controls.
Examples#
// Vulnerable: authenticated, but no ownership check
app.get('/api/invoices/:id', requireLogin, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice);
});
// Fixed: scope the lookup to the caller's account
app.get('/api/invoices/:id', requireLogin, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, accountId: req.user.accountId },
});
if (!invoice) return res.sendStatus(404);
res.json(invoice);
});
# Vulnerable: mass assignment lets a user change their own role
user.update(**request.json) # {"display_name": "Sam", "role": "admin"}
# Fixed: explicit allow-list of writable fields
ALLOWED = {"display_name", "timezone"}
user.update(**{k: v for k, v in request.json.items() if k in ALLOWED})
How to fix it#
1. Enforce authorization on the server for every request, scoped to the caller:
✓ See fixed example 1 in Examples ↑
2. Centralize policy. Put authorization in one place (middleware, a policy library such as CASL, Pundit, OPA or Cedar) instead of ad hoc checks in each handler. Deny by default.
✓ See fixed example 2 in Examples ↑
3. Protect functions, not just pages. Every admin or privileged API route must check the role or permission server-side, whether or not the UI shows the button.
4. Make IDs harder to guess. Random UUIDs reduce enumeration but are not a fix on their own; the ownership check is.
5. Log and alert on authorization failures and unusual access patterns, such as one user reading many different accounts' records.
How to test for it#
- Use at least two accounts per role (and two tenants if multi-tenant). Capture requests as user A and replay them with user B's session.
- Change every identifier you can see: path segments, query parameters, JSON body fields, headers, and IDs embedded in encoded or signed-looking values.
- Call admin and internal endpoints directly as a low-privilege user.
- Try adding privileged fields (
role,isAdmin,tenantId) to update requests. - Automate the matrix (role × endpoint × object owner) in integration tests so regressions are caught before release.
References#
- CWE-639: Authorization Bypass Through User-Controlled Key ↗ (opens in a new tab)cwe.mitre.org
- CWE-285: Improper Authorization ↗ (opens in a new tab)cwe.mitre.org
- OWASP Top 10 A01:2021 Broken Access Control ↗ (opens in a new tab)owasp.org
- OWASP API Security Top 10 API1:2023 Broken Object Level Authorization ↗ (opens in a new tab)owasp.org
- OWASP Authorization Cheat Sheet ↗ (opens in a new tab)cheatsheetseries.owasp.org
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet ↗ (opens in a new tab)cheatsheetseries.owasp.org