Object authorization fails at the query, not at the route.
BOLA and IDOR are the same defect seen from two angles: a lookup that omits the effective subject. SecHelix builds a role by object by action matrix, tests each cell with two real identities, and requires a second identity before calling anything verified.
Part of the AppSec agent guide.
BOLA and IDOR describe the same defect from two angles: a request-supplied key selects the record, and the ownership decision is made from that same untrusted value — or is not made at all. The fix is never a harder-to-guess identifier. It is that the query carries the effective subject as a predicate, at a layer the caller cannot skip.
Reviewing it well means two things most reviews skip. First, the sibling paths: the list endpoint, the export, the search, the bulk operation, and the background worker usually reach the same objects through different code. Second, the compensating control: a predicate-free query is an alarming surface, not a finding, until you have checked whether a database policy actually binds the role that runs it. Both rules come from the evidence contract rather than from this class in particular.
The minimum reproduction of the bug
This is a real evaluation fixture, and it is deliberately this small. Everything that makes a production version hard to see is context around these three lines.
def get_invoice(actor, invoice_id, store):
invoice = store[invoice_id]
return invoiceThe lookup succeeds for any caller who can name an invoice_id. Nothing in the function knows who is asking.
def get_invoice(actor, invoice_id, store):
invoice = store[invoice_id]
if not actor or invoice['tenant_id'] != actor['tenant_id']:
raise PermissionError('forbidden')
return invoiceNote the not actor clause. A missing or unresolved identity must fail closed; a null subject that compares equal to nothing is a silently widened query.
One root cause, six places it surfaces
The coverage model crosses 21 security families with 26 verification lenses. Applied to authorization, the lenses are what stop a review from fixing one endpoint and declaring victory.
| Lens | The question it forces |
|---|---|
| Null or missing identity | Does a missing owner, tenant, actor, or subject fail closed? |
| Alternate endpoint | Does a sibling route, RPC, bulk path, search path, or background worker bypass the primary control? |
| Direct-object access | Can a caller skip the interface and act directly on an object they should not control? |
| Bulk operation | Does the bulk path enforce the same per-object invariant and report partial results honestly? |
| Search, export, list | Can listing, search, export, or autocomplete reveal objects more broadly than the item endpoint allows? |
| Boundary mismatch | Do the interface, the API, the database, the worker, and the provider agree about the same invariant? |
- Enforcement lives in presentation codeThe page hides the button and the API does not check. The control is in the layer that the attacker is not using.
- The effective subject is absent from the data-access contractThe repository method takes an id and returns a row. There is no parameter where ownership could be expressed even if someone wanted to.
- A reporting path borrows a different connectionThe primary path is scoped; the analytics pool connects with a role that bypasses the policy, never sets the tenant setting, and aggregates every tenant.
- The tenant setting is session-scoped on a pooled connectionIt survives past the request that set it and into the next one. Correct code plus a pool equals cross-tenant reads.
Build the matrix before testing a single endpoint
The matrix is not the route table. Routes are what the framework declares; the matrix is what the evidence supports.
For every role x object x action cell, record:
who the effective subject is after authentication
where the ownership decision is made (route, service, repository, database)
which value the decision reads (the request, or the authenticated subject)
what happens when the subject is null or unresolved
whether the list, search, export, and bulk paths reach the same objectsThen reproduce with two identities you own. One identity proves nothing: a request that fails could be failing for any reason, and a request that succeeds could be succeeding legitimately. The Gold Check Pack requires the second identity as a refutation test — repeat with an owned object, to distinguish a general failure from an authorization decision. Running the lane yourself starts with installing the skill.
Use SecHelix to run an authorization review of this repository.
Build the role x object x action matrix from evidence, not from the route table.
Test BOLA/IDOR, BFLA, tenant isolation, ownership, mass assignment, and database policy gaps
separately from the API layer. Use two owned identities for every reproduction.The pair that refutes the finding
These two variants have byte-identical application code. The query in both is SELECT id, title, body FROM documents WHERE id = %s, with no tenant predicate anywhere. Only the migration and the runtime role differ.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY documents_tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
GRANT SELECT, INSERT, UPDATE ON documents TO app_owner;
-- RUNTIME_ROLE = "app_owner"Enabling a row policy does not bind the table owner. The application connects as app_owner, so the policy is present and inert.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY documents_tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
GRANT SELECT, INSERT, UPDATE ON documents TO app_user;
-- RUNTIME_ROLE = "app_user"One extra statement and a different grant. The predicate-free query is now genuinely constrained, and the alarming application code was never the defect.
What the pack demands before this becomes a finding
- An attacker-controlled subject or object identifier.
- A reachable path from the authorized test entrypoint to the lookup.
- An observed failed boundary and a safe isolated reproduction.
- Concrete impact and the defective canonical invariant, stated as an invariant rather than as a line number.
And three refutation tests it must survive.
- Search the lower service, RPC, and database layers for compensating ownership enforcement.
- Repeat with an owned object, to separate a general failure from an authorization decision.
- Reconstruct caller identity resolution without assuming the original hypothesis is correct.
- Pack
SEC-AUTHZ-IDOR-001- Boundary
OBJECT_AUTHORIZATION· actionACCESS- Invariant
- Every protected object access is constrained by the effective subject at a canonical enforcement boundary.
- Sinks
OBJECT_LOOKUP·LIST_QUERY·BULK_OPERATION- Catalog hypothesis
SHX-AUTHZ-L02- Mappings
- CWE-862 · OWASP-ASVS:V4 · OWASP-API1:2023
- Companion pack
SEC-TENANT-RLS-001— tenant isolation at the database boundary- Calibration
- NOT_MEASURED, sample size 0
Test the cell, not the endpoint.
npx skills@latest add omarmohelal/SecHelix --skill sechelix