StackAttestTechnical Trust
Broken access control

How to test your app for broken access control

Two sentences carry this whole subject. Being signed in is not permission to act on a given record. Requests do not come from your interface, so hiding a button changes nothing about what the server will do. Everything below follows from those, and in multi-tenant software this is the failure that costs the most when it goes unfound.

Run a runtime validationRun the free checkSee an example Passport

Two kinds of evidence, and the difference between them

Reading your source can show that an ownership check is missing, which is a strong reason to act and is not the same as a demonstration. Signing in as two accounts and being refused is the demonstration. A report that ran both keeps them apart and says which one produced each result.

Repository validation

E2
Repository

Reads the server's own routes and reports where a record is fetched or changed using an identifier taken from the request with no check that the caller owns it, and where authorisation depends on a value the caller can set. Findings cite the file. What this establishes is that a check is absent, which is a different claim from a request being refused.

Runtime validation

E4
Runtime

Builds and runs the application in an isolated environment, signs in as two separate accounts, and asks for one account's records with the other account's session. What the deployed system returned is the answer to the question people are actually asking, and it is the only layer that can produce it. A route the run could not reach is reported uncovered rather than passed.

Public URL scan

E4
URL scan

Tests the deployed application from outside, the way any visitor reaches it: transport security, security response headers, and what the running service discloses about itself. It needs nothing but the address, and what it observes it observes against the running system. Its limit is reach rather than strength: a URL can never establish authorisation, data-layer or backup posture, and those controls stay uncovered.

How authorisation actually breaks

None of these is exotic. Each one is a reasonable decision that stopped being reasonable when a second customer arrived, and a run establishes which of them are present in your application.

Authentication is not authorisation

A session proves who is calling. It says nothing about whether that person may read, change or delete the record they just named. Where sign-in is open, authenticated means anyone who filled in a form, so a check that asks only whether a caller is signed in has admitted every stranger who registered a minute ago. The two questions are asked in different places and answered by different code, and only one of them is usually written.

An identifier in a request is not a permission

A route that takes a record id, fetches that record and returns it has authorised nothing. The id came from the caller, so changing it is trivial, and there is no version of an id that is hard enough to guess to count as a control. The check that matters compares the record's owner against the caller's identity from the session, on the server, before anything is returned. This is what people mean by object-level authorisation, and its absence is the most common serious finding in software built quickly.

A tenant identifier supplied by the client scopes nothing

Reading the workspace or organisation id from a request body, a query string or a header, and then filtering by it, produces code that looks correct and enforces nothing at all. The value belongs to the caller and travels with the request they control. The tenant has to be derived from the session on the server, and the identifier in the request, if it is used at all, has to be checked against that rather than trusted as it stands.

Hiding a control in the interface is not access control

Removing an admin link for non-admin users, disabling a button, or not rendering a route makes the feature invisible. It does not make it unreachable, because the request that feature would have sent can be sent without it. Every rule enforced only in the client is a rule that applies to people who use your client, which is not the same set of people as those who can reach your server.

Filtering in the frontend query is not enforcing ownership

A client that asks for records belonging to the current user is describing what it wants to display. The filter is part of the request, so removing it is a one-line change to code already running on somebody else's machine. What matters is what the server is willing to return when the filter is not there, and that answer has to be the same whether or not the client cooperated.

One verb checked, the others forgotten

Authorisation is per operation. A read path is often careful and the update, delete or export path beside it is not, because they were written on different days for different reasons. The same gap opens between a detail route that scopes its record and a list route that returns a page of everyone's, and between the interface's own endpoints and the ones an integration or a mobile client uses.

A role the caller can influence

A permission read from a field the user can edit in their own profile, from an unverified token claim, or from a value the client sends back after sign-in, is a permission the user grants themselves. Roles have to come from a source the caller cannot write to, and be checked where they cannot be skipped.

How to test it, with two accounts

You need two accounts in two different tenants, each with data the other should never see. Everything after that is patient repetition.

  1. Create the second account and give it something to lose

    Both accounts need real records, because a test against an empty tenant proves nothing either way. Keep a list of the identifiers each side owns. That list is what turns every later step into a yes or a no.

  2. Replay a real request as the wrong account

    Capture a request the first account makes, then send it again with the second account's session and the first account's identifier. Change nothing else. A refusal is the result you want. A record coming back is broken object-level authorisation, and it is a finding whether the response was easy to obtain or not.

  3. Do it again with no session at all

    Send the same request with the session removed. This separates two states people often conflate: a route open to any signed-in user, and a route open to the internet. The second is worse, the fix is often the same, and you should know which one you have.

  4. Cover every verb and every route, not just the one the screen uses

    For each object, try reading, updating, deleting and exporting it. Then look at the collection routes, the search endpoint, any webhook or integration route, and anything the interface never calls but the server still serves. Rules written per screen leave gaps exactly where no screen exists.

  5. Decide in advance what a refusal looks like

    Write down the response you expect when the answer is no, then compare against it. A 403 and a 404 are both defensible, and returning a 200 with an empty body is usually fine as well. What none of them may do is return the record. Judge the body, not the status code alone, because a well-formed error page with the data underneath it is still a leak.

What the result looks like

An illustrative extract, not a real customer result. Every row carries the evidence grade behind it, and a control nobody could establish says so.

ControlStatusEvidenceNote
Object ownership is enforced server-sideFailedE4One account's record was returned to a signed-in caller from another tenant
Server-side input validation on state-changing endpointsPartialE2Two write routes accept a workspace id from the body without checking it against the session
Injection-safe data accessVerifiedE2Queries are parameterised throughout the data layer
Error responses do not leak internalsVerifiedE4A refused request returned no detail about the record
Authentication endpoints are rate limitedUncoveredNot gradedNot reachable by the layers that ran, so it is reported as uncovered rather than assumed

Mapped to published standards

Each control is tested against a named clause, so a result means something outside our own vocabulary. Mapping is not certification, and none of these bodies endorse StackAttest.

OWASP ASVS 5OWASP API Security Top 10OWASP WSTGCWE

What this does not tell you

  • It is not a penetration test. A deterministic check and a runtime probe are not an adversarial human engagement, and there are classes of weakness only a person hunting for them will find.
  • It is not SOC 2 and does not replace it. SOC 2 attests to organisational controls over time; this validates technical controls in the software. They answer different questions for different buyers.
  • It does not replace human due diligence. It gives a reviewer repeatable evidence to start from, which is a different thing from being the reviewer.
  • Controls the run could not reach are reported as uncovered rather than passed. A result you did not earn is worse than no result.
  • Source evidence and runtime evidence are not interchangeable here. Reading the code can show that an ownership check is missing. Only a run with two accounts can show that a request was actually refused, and the report names which of the two stands behind each result rather than presenting them as one number.
  • A run tests the routes it can reach. A feature behind a plan the test accounts do not hold, a flow that needs a real payment, or a step that waits on a person is reported as uncovered rather than passed.
  • A public URL check cannot establish any of this. It has one anonymous view of your application and authorisation is a question about two accounts, so the free check names these controls as uncovered instead of guessing at them.

Questions

How do I test for broken access control?

With two accounts in two tenants and a request you replay. Capture something the first account does, send it again with the second account's session, and see whether the record comes back. Repeat for every verb and every route rather than only the ones your interface uses. Then send the same request with no session, so you know whether the route is open to any user or to anyone at all.

What is an IDOR, and is it different from broken access control?

An insecure direct object reference is one shape of it: a route that acts on an identifier from the request without checking that the caller owns the object behind it. Broken access control is the wider category, which also covers roles that are not enforced, rules applied only in the interface, and tenant scoping taken from something the caller supplies.

My users can only see their own data in the app. Isn't that enough?

That describes your interface, and requests do not have to come from your interface. What decides the answer is what the server returns when a request asks for someone else's record without the filter your client would have added. If you have never sent that request, you do not yet know.

Are unguessable identifiers a defence?

No. Making an identifier long makes it harder to stumble on and does nothing once it is known, and identifiers leak through shared links, exports, referrers, support tickets and logs as a matter of routine. Treat an identifier as public and put the check on the server.

Can you find this by reading my code?

Partly, and the distinction is worth being exact about. Reading the source can show that a route acts on an identifier from the request with no ownership check, which is a strong reason to fix it. It cannot show what the deployed system does, because a check may exist somewhere else in the request path. Only a run that signs in as two accounts and is refused establishes that.

Does the free check test this?

No, and it says so. It looks at your application the way an anonymous visitor does, which cannot answer a question about one account reaching another account's data. Those controls come back as uncovered, which is the honest result for something the layer that ran could not reach.

Related

Supabase RLS auditAI app security auditVibe code auditExposed API keysFree AI app security check

Find out what is actually true of your application

Start with the layer you can run today. The report says which layers ran and what they could not reach, so the result is honest about its own limits.

Run a runtime validationRun the free check