Problem and Applicable Scenario
A B2B document service uses a shared database. A user can belong to several tenants and have a viewer, editor, or admin role in each one. A document can also be shared directly with a member of the same tenant. The service exposes single-document read, update, and delete APIs, bulk export, and asynchronous archive generation. JWT signature, expiry, and audience validation already work, but legacy code globally loads a document by a client-supplied document_id and then checks only whether the request is authenticated.
An attacker obtains a valid UUID from an audit entry, a share link, or another API and replaces their own document ID with it. The UUID is difficult to enumerate, but the server will still disclose or modify the document unless it checks whether the current subject can perform this action on this resource in the active tenant. OWASP calls this Broken Object Level Authorization (BOLA); common web-security material also uses Insecure Direct Object Reference (IDOR). An object identifier can appear in a path, query parameter, header, JSON body, GraphQL variable, filename, or bulk list.
Two public API interview-preparation pages explicitly ask candidates to explain or test IDOR. A January 2026 public discussion asks whether encrypting object IDs fixes IDOR, showing that “hide the ID” remains a practical misconception candidates need to analyze. OWASP API Security Top 10 API1:2023 and a public 2026 empirical study provide additional risk and technical context. This evidence supports representativeness; it does not establish a particular company's question or interview frequency.
This is a backend question because the core task is carrying authenticated identity, tenant relationships, resource attributes, and actions into one authorization boundary across APIs, data access, transactions, caches, and workers. The existing OAuth article covers identity and authorization-code flows, SQL injection covers query structure, and SSRF covers outbound destination authorization. None answers object-level access.
What the Interviewer Is Evaluating
The first signal is precise terminology. Authentication answers “who is calling?” Object-level authorization answers “may this subject perform this action on this object now?” A user who may call PATCH /documents/{id} but may not modify another tenant's document exposes BOLA. A regular member reaching an admin-only bulk-delete function is closer to Broken Function Level Authorization. Letting a user update the protected owner_id property on their own document is an object-property authorization problem.
The second signal is a complete authorization model. Comparing only document.owner_id == user.id misses tenant roles, team sharing, read-only rights, revocation, and temporary support access. A strong answer makes one decision explicit:
allow(subject, tenant, action, resource, context) -> decision + reasonThe tenant comes from a verified membership. Actions distinguish read, update, delete, share, and export. Resource attributes include tenant, state, owner, and sharing relationships. Context can contain a temporary support grant and policy version. If no allow rule matches, the decision is deny.
The third signal is enforcing the boundary where data is selected and changed. Loading globally with findById(id) and relying on one controller to add a check creates bypasses through bulk endpoints, workers, caches, and new routes. The recommended path queries from an authorized data set. A mutation carries scope and version conditions into the same statement or transaction and verifies the affected-row count.
The fourth signal is understanding defense-in-depth limits. Random UUIDs, 404 responses, rate limits, and PostgreSQL Row-Level Security (RLS) all help, but none replaces full business authorization. PostgreSQL 18 documentation also states that superusers, BYPASSRLS roles, and normally table owners bypass RLS. Policy enablement, connection roles, and per-request tenant context must be verified.
The final signal is proving that every path denies cross-object access. A successful GET for one's own document is a functional test. Security tests use multiple accounts, tenants, roles, and known valid foreign object IDs. They cover reads, writes, deletes, bulk operations, exports, downloads, GraphQL, cache hits, and worker execution, and assert that no database, object-storage, or messaging side effect occurred.
Questions to Clarify Before Answering
- How is a tenant selected? A user may switch active tenants, but a client
X-Tenant-IDexpresses only a
choice. The server must revalidate membership for the authenticated identity instead of trusting the header.
- What determines permission? Is it only a tenant role, or also ownership, team membership, direct sharing,
document state, and temporary support grants? More dynamic rules need a central policy and stable audit reasons.
- Which actions require separate permission? Read does not automatically include download, export, share,
or delete. A bulk operation applies the same action-level decision to every object.
- Should denial return 403 or 404? If an external object ID should not reveal existence, missing and invisible
can share a 404 contract. A visible same-tenant object with insufficient action permission can return 403 under the product contract. Neither response may expose sensitive differences.
- Do jobs use current permission or a submission-time snapshot? This design checks at enqueue and again at
execution, so a queued export stops after revocation. If immutable snapshot rights are required, model an explicit, scoped, expiring grant.
- Can support staff access tenants? If yes, use a separate just-in-time path requiring a ticket, reason,
approval, expiry, and full audit. Do not give the general application connection a permanent global-admin switch.
- Will PostgreSQL RLS be used? This answer uses application tenant scoping as the primary control and RLS
against omissions. Other combinations are valid if database roles, pool context, migrations, and workers keep their guarantees.
30-Second Answer Framework
“I would express authorization as subject + tenant + action + resource + context and deny by default. The authentication middleware establishes only the subject. The active tenant must come from a verified membership. The data layer does not expose an unscoped document lookup: reads use tenantid + documentid, followed by action policy over role, owner, and sharing. Updates and deletes carry those conditions into the same statement or transaction; zero affected rows is handled as invisible. A UUID reduces enumeration but never replaces access control.
Bulk, cache, download, and export paths use the same rules. Cache keys include tenant; capability links bind resource, action, and expiry; and a job checks at enqueue and execution. PostgreSQL RLS can backstop mistakes, provided the application role cannot bypass it and pool tenant switching is tested. Finally, I would substitute IDs in paths, bodies, and bulk lists across a two-tenant, multi-role matrix and assert no response, data, file, or message crosses the tenant boundary.”
Step-by-Step Deep Dive
Step 1: Inventory authorization tuples and object-entry points
Write the business rules as a matrix before scattering role names through controllers:
| Subject relationship | read | update | delete | share | export | |---|---:|---:|---:|---:|---:| | Tenant viewer, not shared | Deny | Deny | Deny | Deny | Deny | | Tenant viewer, directly shared | Allow | Deny | Deny | Deny | Allow or product-specific | | Tenant editor | Allow | Allow | Deny | Policy-specific | Allow | | Document owner | Allow | Allow | Policy-specific | Allow | Allow | | Tenant admin | Allow | Allow | Allow | Allow | Allow |
This table is a scenario assumption. Product and security owners must approve the real matrix and justify each allow rule. Default deny means a new action, unknown role, missing tenant context, or policy error fails closed.
Then inventory object-entry points: path parameters, query filters, bodies, bulk arrays, GraphQL nodes, share tokens, object-store keys, cache keys, messages, and jobs. OWASP's definition does not require a sequential ID. UUIDs, filenames, and generic strings are also references. For each entry, record subject source, tenant source, action, lookup method, and final side effect. That process exposes bypasses.
Step 2: Derive a trusted tenant from authenticated identity
A verified JWT provides a stable user_id; it does not make every long-lived role claim current or make a separately supplied tenant ID trusted. When a user chooses tenant_b, the server resolves a current membership and creates a request-scoped AuthContext:
AuthContext {
user_id,
tenant_id,
membership_id,
roles,
policy_version,
support_grant_id?
}Middleware ensures the context exists; the domain policy still decides resource actions. Connection pools, consumers, and concurrent requests must not share a mutable global tenant. Set database context inside each transaction and clear it before returning the connection so one request cannot inherit the previous tenant.
Step 3: Put tenant scope into queries and data constraints
The unsafe query loads globally:
SELECT * FROM documents WHERE id = :document_id;The basic boundary includes the trusted tenant:
SELECT *
FROM documents
WHERE tenant_id = :auth_tenant_id
AND id = :document_id;If direct sharing determines visibility, join the share relationship inside the scoped query or load only the same-tenant resource and pass it to a central policy engine. Never copy tenant_id from the body into this predicate. Externally, zero rows can consistently produce 404 so “exists in another tenant” and “does not exist” are not distinguishable.
Schema design makes mistakes harder. Share, version, attachment, and export-item tables all carry tenant_id. Where appropriate, unique and foreign keys use (tenantid, resourceid) so a child in one tenant cannot point to a parent in another. Application authorization remains necessary; constraints reject accidental cross-tenant relationships at write time.
Step 4: Keep authorization and mutation in one correctness boundary
“Load, authorize in application code, update later” has a time-of-check/time-of-use race. Permission or document state can change between the steps. A simple policy can use a conditional update:
UPDATE documents
SET title = :title, version = version + 1
WHERE tenant_id = :auth_tenant_id
AND id = :document_id
AND version = :expected_version
AND (
owner_id = :user_id
OR :can_edit_tenant_documents
);When zero rows are affected, do not publish a message, write a success audit entry, or update a cache. Complex sharing can lock relevant membership and resource versions in one transaction, or compile policy into a database predicate. Authorization evidence and side effects must share an explicit transaction/version boundary; an old snapshot cannot authorize an unconditional later write.
Bulk operations need an atomicity contract. This scenario uses all-or-nothing: normalize and deduplicate all IDs, query the authorized set under one trusted tenant and action, and reject the batch if counts differ. Silently exporting the visible subset can become an existence oracle. A per-item product contract is also possible, but it must authorize each object and use a non-disclosing result for denied items.
Step 5: Cover caches, downloads, and asynchronous jobs
A cache key includes at least tenant and resource version, such as document:{tenantid}:{documentid}:{version}. A hit retrieves data; it does not skip current action authorization. If decisions are cached, the key must cover subject, tenant, action, resource, relationship or policy version, and revocation. Complex systems are often safer caching relationship data and recomputing a small decision.
A presigned download URL is a short-lived capability. Once issued, a bearer may reach storage during its lifetime. Check download before issuing it and bind the resource, action, expiry, and content disposition. Sensitive documents use short lifetimes, one-time or proxied downloads, and revocation where required. A signature proves the server issued the URL; it does not grant an unauthorized caller permission to obtain it.
An export checks at two moments. The API verifies export on every document before enqueueing. At execution, the worker uses subject and tenant references in the job to reload current membership and resource permission. Revoked membership, a tenant change, or expired support grant stops the job before a downloadable artifact exists. The job payload cannot accept a caller-supplied admin flag or preserve a role array forever.
Step 6: Treat PostgreSQL RLS as testable defense in depth
Shared tables can enable RLS so a policy filters existing rows by trusted transaction tenant and WITH CHECK constrains inserted or updated rows. Default deny when no policy applies is a desirable failure mode. Before release, verify all of the following:
- the application connection is not a superuser, lacks
BYPASSRLS, and is not a table owner that normally
bypasses policy; use FORCE ROW LEVEL SECURITY when appropriate;
- every transaction sets the tenant and clears it before pool return; missing context denies instead of selecting
a default tenant;
USINGcovers visible old rows andWITH CHECKcovers inserted or updated new rows;- migrations, backups, workers, and support tools use separate roles and explicit procedures;
- permissive policies combine with
ORby default, so an added policy cannot accidentally broaden access;
complex subquery policies are also reviewed for concurrency snapshots and cost.
RLS cannot express every product relationship and cannot protect object storage or a search index that bypasses the database. Application policy owns full business semantics; RLS stops leakage if one query omits tenant scope. Test both layers against the same permission matrix for consistency.
Step 7: Design errors, audit, and falsifiable tests
For cross-tenant or invisible objects, this scenario returns a uniform 404 and response shape. A denial does not return title, tenant name, owner, version, file size, or a distinct timing hint. Rate limiting reduces enumeration and audit noise, but the security claim never depends on an attacker failing to guess a UUID.
Audit actor, trusted tenant, action, a controlled representation of resource ID, decision, reason code, policy version, support grant, and trace ID. Do not log document content, download capabilities, or full JWTs. Useful signals include cross-tenant denials, one subject probing many missing objects, policy errors, RLS denials, jobs stopped after revocation, and support-grant use.
The test matrix includes at least:
- two tenants, each with owner, viewer, editor, and admin, plus a revoked user and temporary support operator;
- own, same-tenant unshared, directly shared, other-tenant valid UUID, and nonexistent UUID resources;
- list, GET, PATCH, DELETE, share, bulk export, GraphQL, download, cache hit, and worker execution;
- substitution in paths, queries, JSON, arrays, nested GraphQL variables, storage keys, and job payloads;
- no response leak on reads and no version, share, file, message, search-index, or success-audit change on denial;
- concurrent revocation and update, successive tenant use of one pool connection, missing RLS context,
misconfigured application role, and stale authorization cache.
Generate positive and negative CI cases from the authorization matrix, and require each new endpoint to register its resource and action. Scanners find some enumerable paths, but they do not know business ownership. Multiple accounts, known valid foreign objects, and side-effect assertions are the decisive evidence here.
High-Quality Example Answer
“I would classify this defect as BOLA. The JWT proves caller identity, while the API lacks action-level permission for the target document. UUIDs reduce enumeration probability but do not change the authorization decision. I would first define a subject, tenant, action, resource, context matrix and deny by default. A user can select an active tenant, but the server derives tenant context from a verified current membership.
The data layer no longer exposes global findById to business routes. A read first scopes by trusted tenantid + documentid, then applies role, ownership, and sharing rules for read, update, delete, share, or export. A simple update carries tenant, object, action conditions, and resource version in one conditional statement. Zero affected rows stops every side effect. Complex policies hold relevant versions in one transaction. Composite tenant foreign keys prevent cross-tenant child relationships.
I would include every bypass in that model. Bulk export authorizes every ID and is all-or-nothing here. Cache keys include tenant and cache hits still reauthorize. Download links require download permission at issuance and bind the object to a short expiry. Jobs check at enqueue and execution so revocation takes effect before export. RLS is a backstop, but the application role must lack BYPASSRLS and table ownership, and pool transaction context must be tested for tenant leakage.
Finally, I would use multi-role accounts across two tenants and place one known valid foreign UUID into paths, JSON, bulk lists, and GraphQL. Tests cover read, write, delete, export, download, cache, and worker paths. Every denial asserts a non-disclosing response and no database-version, file, message, or search-index change. This proves object-level authorization path by path while covering login, error responses, and side effects.”
Common Mistakes
- Treating UUID, Base64, or encrypted IDs as the fix → IDs leak through logs, shares, or other APIs, and a
valid reference still crosses the boundary → **Authorize subject, tenant, object, and action on every request; use random IDs only as defense in depth.**
- Allowing any ID after JWT validation → Authentication identifies the subject but grants no resource right →
Build tenant context from verified membership and then make the object decision.
- Globally loading in controllers and hand-writing an owner check → New routes, bulk, cache, and workers omit it,
while team sharing is falsely denied → Expose tenant-scoped data access and central policy with default deny.
- Testing only GET → PATCH, DELETE, export, GraphQL, and downloads may still leak or mutate → **Generate a
multi-account negative matrix across entry points and actions.**
- Filtering denied IDs out of a successful batch → Counts and contents become an existence oracle, and partial
success is ambiguous → Predefine all-or-nothing or per-item semantics and authorize every object.
- Assuming RLS is automatic safety → Table owners, superusers,
BYPASSRLS, missing context, and permissive policy
composition can break isolation → Verify roles, transaction context, USING/WITH CHECK, and failure modes.
- Asserting only 403 or 404 → The system may have written data, sent a message, or generated a file before denial →
Assert every persistent and external side effect remains unchanged.
- Logging full objects and tokens for investigation → Security telemetry becomes another data leak → **Record
minimal identity, action, reason, and a controlled resource identifier.**
Follow-up Questions and Responses
Follow-up 1: If UUIDv4 is effectively unguessable, is object authorization still necessary?
Yes. References leak through share links, browser history, logs, notifications, analytics, another API, or a misdirected message. UUIDs reduce blind enumeration; they express no owner, tenant, action, or expiry. Give the attacker account one known valid foreign UUID in a test. Successful access immediately proves missing authorization.
Follow-up 2: Does returning 404 for every cross-tenant object make debugging too hard?
External responses can be uniform while internal audit keeps stable reasons such as RESOURCENOTVISIBLE, ACTIONDENIED, or TENANTCONTEXT_INVALID. Operators investigate through controlled logs and a trace ID; callers do not learn whether an object exists. If same-tenant collaboration needs “no edit permission,” return 403 only after establishing that the object is visible, under a consistent API contract.
Follow-up 3: What happens to queued exports when a user is removed from a tenant?
Successful submission does not create permanent read authority. Before execution, reload membership and export permission for every resource. After revocation, deny the job, delete temporary files, and issue no download URL. If compliance requires rights frozen at submission, create an explicit short-lived authorization snapshot with scope, approver, and expiry instead of silently preserving stale JWT roles.
Follow-up 4: If every application query already includes tenant_id, what does RLS add?
It can stop a new query that omits the predicate and some direct database paths, reducing the blast radius of one miss. It also adds pool context, role, migration, and policy-maintenance costs and cannot protect search, cache, or object storage. High-sensitivity shared tables are good candidates for both layers. First prove that the application role cannot bypass RLS, missing tenant context denies, pool context does not leak, and both layers match the same matrix.
Follow-up 5: Support engineers need temporary customer-document access. How do you avoid a permanent backdoor?
Create separate just-in-time support authorization bound to a ticket, target tenant, allowed actions, approver, short expiry, and reason. Sensitive actions can require two-person approval. Separate support and normal endpoints, show a prominent session state, prohibit bulk download, and audit every object access. Expiry revokes access immediately, and usage is reviewed periodically. General application roles and service tokens receive no cross-tenant permission.