Skip to content
SecHelixv3 alpha
GitHub
DocsContributeSupportWorkbenchGitHub
Back to overview
Review guide · Authorization

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.

SEC-AUTHZ-IDOR-001SEC-TENANT-RLS-001Two identities required

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.

No subject in the decision
def get_invoice(actor, invoice_id, store):
    invoice = store[invoice_id]
    return invoice

The lookup succeeds for any caller who can name an invoice_id. Nothing in the function knows who is asking.

Subject constrains the object
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 invoice

Note 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.

LensThe question it forces
Null or missing identityDoes a missing owner, tenant, actor, or subject fail closed?
Alternate endpointDoes a sibling route, RPC, bulk path, search path, or background worker bypass the primary control?
Direct-object accessCan a caller skip the interface and act directly on an object they should not control?
Bulk operationDoes the bulk path enforce the same per-object invariant and report partial results honestly?
Search, export, listCan listing, search, export, or autocomplete reveal objects more broadly than the item endpoint allows?
Boundary mismatchDo 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.

What each cell records
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 objects

Then 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.

Session prompt
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.

Policy enabled, owner exempt
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.

Policy forced, role separated
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 · action ACCESS
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
Install

Test the cell, not the endpoint.

npx skills@latest add omarmohelal/SecHelix --skill sechelix