Broken Access Control: The Most Common Yet Preventable Risk

Broken Access Control: The Most Common Yet Preventable Risk

pr0h0
access-controlcybersecurityweb-securityrisk-management
AI Usage (88%)

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.

LayerWhat to verifyCommon mistake
APIOwnership and tenant scopeOnly checking login status
UIButton visibilityTreating hidden controls as security
PolicyRole-to-action mappingAd 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.

Share this post

More posts

Comments