Authorise The Object, Not The Screen
A platform that serves public market information and private executable quotes from one state core has to check its boundary against the object being requested, not against the screen that requested it. Per-screen checks pass while an edited identifier in a request reaches another counterparty's private price. Broken object level authorisation sits consistently near the top of published web application risk lists because it is invisible to every test that only drives the user interface.
Authorise The Object, Not The Screen
The short answer. When one state core serves two audiences, with public market information on one side and private executable quotes on the other, the boundary has to be enforced against the specific object a request names, not against the route or screen that named it. A per-screen check asks whether this user may use this page, which they may, and then hands back whichever record the identifier in the request points at. Change that identifier and you are reading another counterparty's private price. This is broken object level authorisation, which appears consistently near the top of published web application risk lists, and it passes every test that only drives the interface.
Consider a platform with two faces. A public one, showing indicative market information that anyone is meant to see. And a private one, where a named counterparty sees quotes priced specifically for them, which they can accept and which bind.
Both read from the same state core. That is the right architecture, because the alternative is two copies of the same facts drifting apart. It also means the only thing separating a published indicative price from a private executable one is a check in code.
The screen is not the subject of the check
The failure mode is boring, which is why it keeps happening.
A developer builds the private quote screen. Access to it is gated: you must be signed in, you must be a counterparty rather than a public visitor, perhaps you must hold a particular role. All true, all enforced, all passing.
The screen then fetches a quote by identifier. The identifier arrives in the request, because that is how the screen knows which quote to display. The handler looks it up and returns it.
Nothing in that path asked whether this quote belongs to this counterparty. The check that ran was about the screen. The data that came back was about the object. Those are different questions and only one of them was asked.
So a counterparty who is entirely legitimate, signed in with their own credentials, changes a number in a request and receives a quote priced for someone else. They learn what a competitor is being offered, on which grades, at what margin. In a market where price is the product, that is the most commercially sensitive information the platform holds.
There is no exploit here in the usual sense. No injection, no stolen token, no privilege escalation. The attacker is a valid user asking a valid endpoint for a record using a valid request shape. The only unusual thing they did was supply a different identifier, which anyone can do, and which browser tooling makes trivial.
Why this survives review and testing
Three reinforcing reasons, and each needs its own answer.
Interface-driven testing cannot see it. Every test that clicks through the application supplies identifiers the application itself produced, so every test requests objects the user owns. The suite is green and stays green. The vulnerability lives in the space of requests the interface never generates, which is precisely the space nobody tested.
The check feels like it already happened. By the time a handler runs, the request has passed authentication and route authorisation. It is genuinely easy to read that as "this request is authorised" when it only means "this caller may reach this code". The gap between those two statements is where the whole class of bug lives.
The identifiers look unguessable. Teams often rely on the fact that keys are long random values rather than sequential integers. That raises the effort and nothing else. Identifiers leak constantly through shared links, exports, support tickets, notification emails, logs and an earlier legitimate response. Unguessability is not an access control, it is a delay.
The reason this class dominates published risk lists is a simple consequence of the above. It is present in a very large share of applications that have authentication done properly, and it is almost never visible from the outside of a normal test suite.
How to structure the check so it cannot be skipped
Advice to remember the check is worthless. The structure has to make omission hard.
Make ownership a parameter of every read, not a filter afterwards. The data access layer should not expose a function that fetches a quote by identifier alone. It should expose one that fetches a quote by identifier for a given counterparty, and returns nothing when the two do not match. If the unscoped version does not exist, it cannot be called by accident. This is the single highest-value change available and it is mostly a refactor.
Derive the subject from the session, never from the request. The counterparty identity comes from the authenticated session or token. The instant a request body or query parameter supplies the counterparty, the attacker controls both sides of the comparison and the check is decorative.
Push it into the query where the engine enforces it. In a relational store, row-level security applies the predicate inside the database, so a handler that forgets its filter still returns nothing. That turns a missing check from a data leak into an empty result, which is the correct direction for a mistake to fail.
Treat a mismatch as not found. Returning a distinct response for "exists but is not yours" confirms the existence of another party's record and often its shape. Return the same answer you would give for an identifier that does not exist at all.
Authorise writes against the object too. Reads get the attention because they leak. Writes are worse. An accept, cancel or amend applied to an object the caller does not own moves someone else's exposure, and that is a trade dispute rather than an information disclosure.
Enforce at the state core, not per interface. Two audiences over one core means the boundary belongs where the state lives, not duplicated into every presentation path. If the public surface and the private surface each implement their own version of the rule, they will disagree, and the lenient one defines your actual security.
Testing for it adversarially
This is the part teams skip, and it is the only part that gives you evidence.
The method is to write tests that behave like an attacker rather than like a user. Create at least two counterparties with real data. Then, as the first counterparty, request every object belonging to the second, by identifier, directly against the interface the application exposes. Assert that each one is refused.
What makes it work in practice:
Enumerate endpoints from the code, not from the screens. The gap is exactly the set of requests the user interface does not generate. A list derived from clicking around reproduces the blind spot you are trying to escape.
Cover every method, not just the read. For each object, attempt to read it, modify it, act on it and delete it as the wrong party. These are separate code paths and in my experience they are not uniformly protected, because the read path got reviewed after someone raised the concern and the write paths did not.
Include the nested identifier. Objects reached through a parent are the most frequently missed, because the parent was checked and the child was assumed to be in scope. A quote line, an attachment, a counterparty contact, a settlement instruction. If the request names it, authorise it.
Make it a standing test, not an audit. The check belongs in the suite that runs on every change, because the next new endpoint is the next instance of this bug. An audit tells you the state of the system on one day. A test tells you about every day after it.
Assert the response is indistinguishable. The test should check not just that access was refused, but that the refusal looks identical to a request for something that does not exist. Otherwise the system is still answering the question of what exists.
One further reason to automate it: this class of bug does not announce itself in production. A successful read of someone else's quote returns a normal response with a normal status code. There is no error, no alarm, no anomaly in a dashboard. It will sit there until a counterparty notices and tells you, or does not tell you.
The honest limit
Object level checks solve one problem completely and leave adjacent ones open.
They say nothing about field level exposure. A caller may be entitled to the object and not to every attribute on it, and a correctly authorised response can still return an internal margin or a cost basis that the counterparty should never see. That is a separate decision about what each audience's view of an object contains, and it has to be made per field.
They also say nothing about aggregates. Indicative market information derived from private trades may be public by design, but if the aggregate is thin enough that a single trade can be inferred from it, the object level boundary held while the sensitive fact escaped anyway. That is a disclosure question about the aggregation, not an authorisation question.
Row-level enforcement in the database is a strong backstop and not a complete one, because reads that bypass it exist in most systems. Background jobs, migrations, administrative tooling and anything running with elevated credentials are all outside the predicate. Those paths need their own discipline.
And none of this helps if the ownership model itself is wrong. If a counterparty record is linked to the wrong parent, or a user is attached to two organisations when they should be attached to one, every check will enforce an incorrect boundary perfectly. The mechanism is only as good as the relationships it reads.
FAQ
What is broken object level authorisation in plain terms? It is when a system checks whether a caller may use an endpoint but not whether they may access the specific record that endpoint was asked for. The caller is a legitimate user making a well-formed request. They change an identifier and receive someone else's data. It appears consistently near the top of published web application risk lists.
Why didn't our test suite catch this? Because tests that drive the interface only ever request identifiers the application itself supplied, so every request is for an object the test user owns. The vulnerable requests are the ones the interface never generates. You have to construct them deliberately, with two separate accounts, and assert that each cross-account request is refused.
Aren't random unguessable identifiers enough protection? No. They raise effort and provide no boundary. Identifiers leak through shared links, exports, support tickets, notifications, logs and earlier legitimate responses, and anyone holding one can replay it. Treat unguessability as a delay, never as an access control, and authorise the object on every request regardless of how the identifier looks.
Where should the check actually live? At the state core, as close to the data as you can get it. Make ownership a required parameter of every read so an unscoped fetch does not exist to be called, and back it with row-level enforcement in the database so a forgotten filter returns nothing instead of leaking. Duplicating the rule per interface guarantees the two versions eventually disagree.
Should the response differ between not yours and not found? No. A distinct response for an object that exists but belongs to someone else confirms that it exists and often reveals its shape, which is a disclosure in itself. Return the same answer in both cases, and assert that indistinguishability in tests rather than assuming it.
Does this matter more for reads or writes? Writes are worse, though reads get more attention. A read of another counterparty's private quote discloses commercially sensitive pricing. A write applied to another counterparty's object moves real exposure, which becomes a trade dispute rather than an information incident. Authorise accept, amend, cancel and delete against the object exactly as you authorise the read.
The five signs that a failure is in the seams, then one register per boundary covering units, ranges, timing, retry ownership and partial-success behaviour. Filled in separately by both sides, which is the whole method.
Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.
Bring us the program