
Broken Access Control: The Most Common Yet Preventable Risk
Broken access control usually comes from trusting the wrong layer. The UI may hide the button, the role badge may look correct, and the route may sound protected. None of that matters if the backend accepts the action from the wrong account.
Why Broken Access Control Keeps Showing Up
I keep seeing the same shape in real apps: the product grows, roles multiply, and authorization gets spread across too many places. A feature starts with one owner check. Then someone adds export access, then a support flow, then an admin override. Each change is small. Together they leave gaps.
The tricky part is that these bugs rarely look broken in the browser. The page still loads. The button still disappears for the wrong user. The actual failure is on the server, where no one enforced the rule.
What Counts as Broken Access Control
Broken access control is any case where a user can perform an action or access data they should not.
IDOR and object-level authorization
The classic version is IDOR: insecure direct object reference. The client sends an object ID, and the server returns it without checking ownership or tenant scope.
If a request like this succeeds for the wrong account, you have a problem:
fetch("/api/invoices/481516", {
headers: { Authorization: `Bearer ${token}` }
});
The object identifier is not the issue. The missing authorization check is.
Function-level access checks
Some bugs are not about data rows. They are about actions. A non-admin account should not be able to export all users, reset another user's password, or toggle billing settings. If the route exists and the server only checks that the user is logged in, the function is exposed.
Role drift and privilege creep
Roles drift over time. A support role starts with read-only access, then gets one “temporary” exception, then another. Months later nobody can explain why half the endpoints trust that role.
How These Bugs Show Up in Real Apps
Trusting client-side state
A frontend flag like isAdmin, canExport, or hasProAccess is fine for rendering. It is not security. If the server accepts the same action after the client flips that flag, the app is trusting a value the user controls.
Missing backend ownership checks
This is the most common failure in CRUD apps. The server validates that the request body is well-formed, but never checks whether the current user owns the target record.
Impact is usually straightforward:
- cross-account data reads
- unauthorized edits
- deleted records from another tenant
- exported data that should stay private
Admin routes exposed by guessable IDs
Sometimes the route is hidden but the object namespace is predictable. I have seen /admin/users/123, /admin/users/124, and bulk endpoints that accepted arbitrary ID lists. Once the pattern is clear, the only thing preventing abuse is the server-side authorization code.
A Practical JavaScript Testing Workflow
Reproduce the action with two accounts
Start with two safe test accounts: one low privilege, one higher privilege. Perform the same workflow in both. Save the request details and compare what changes.
Compare requests, not just UI behavior
Open the network tab and inspect:
- URL path
- HTTP method
- object IDs
- tenant or org identifiers
- role-related fields
- hidden form values
If the low-privilege user can replay the same request and get the same result, the UI was only decoration.
Modify IDs, roles, and feature flags safely
A simple test script helps you avoid guesswork:
async function tryRequest(path, token, body) {
const res = await fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
});
return { status: res.status, text: await res.text() };
}
Then vary one thing at a time:
- target ID from another account
- tenant ID from another workspace
- role field removed from the payload
- feature flag set to false
Do not brute force. You are testing authorization boundaries, not trying to break the system.
What Good Defense Looks Like
Enforce authorization on every request
The backend should verify access on the action itself, not rely on the session having “the right screen.” That means checking ownership, role, and tenant scope at the point where the data is read or changed.
Use deny-by-default route handling
If a route or action has no explicit policy, it should fail closed. This matters in large apps because new endpoints are often added faster than policy review.
Centralize policy checks and test them
I prefer one policy layer over scattered checks in controllers and components. Centralization makes review easier and reduces drift. It also makes testing simpler because you can write direct tests for each rule.
| Layer | What to verify | Common mistake |
|---|---|---|
| API | Ownership and tenant scope | Only checking login status |
| UI | Button visibility | Treating hidden controls as security |
| Policy | Role-to-action mapping | Ad hoc exceptions in one route |
Common Mistakes in Fixes
Hiding controls instead of checking permissions
If you only remove a button, the endpoint may still work. That is not a fix. It is a layout change.
Relying on front-end route guards alone
Route guards help with UX, but they are not enforcement. Any user who can call the API directly can bypass them.
Adding one-off checks that drift over time
A patch like “check admin here” or “check owner here” often turns into a pile of exceptions. The code gets harder to audit, and the next feature misses the same rule.
Verification Checklist
Test read, write, delete, and export paths
Do not stop after verifying one endpoint. Broken access control often shows up in read paths first, then write paths, then bulk actions.
Confirm cross-tenant boundaries hold
If your app has organizations, workspaces, or projects, test that data cannot cross that boundary even when the object ID is valid.
Re-run tests after role or schema changes
Authorization bugs return when roles change or new tables appear. Re-test after migrations, new admin features, and any refactor that touches request handling.
Conclusion
Broken access control stays common because it is easy to get wrong and easy to miss in review. The fix is not flashy: enforce policy on the server, test with real accounts, and treat client-side controls as hints only. If you keep doing that, most of these bugs stop being mysterious and start being routine to catch.


