API security: prevent BOLA and authorization gaps across databases, caches, and jobs
An authenticated user must not read another company's invoices. Follow an invoicing SaaS to distinguish authentication from authorization, prevent BOLA and IDOR, restrict writable fields, isolate caches, reauthorize exports, and verify safe logging.
API security: what comes after authentication?
Requiring login does not prevent a leak if replacing an invoice ID returns another company's invoice. Authentication establishes identity; authorization decides whether that caller may perform this action on this object. Even a user with a valid token has operations the API must reject.
The 2023 OWASP API Security Top 10 lists Broken Object Level Authorization as API1. BOLA occurs when a caller may use an endpoint but the endpoint fails to check access to the requested object. Insecure Direct Object Reference, or IDOR, is a common way to encounter this problem: an identifier leads to someone else's data without the required authorization. UUIDs make guessing harder, but access checks must still stop callers who learned an ID through a shared URL.
Map the data flow and assign controls to boundaries
Protect invoice numbers, amounts, recipients, CSV exports, and credentials. Looking only at the screen misses export workers and log collectors. Threat modeling maps data flows and boundaries where trust assumptions change, then connects abuse paths to mitigations and verification.
User → API (identity, membership, action permission) → API → database / cache (object, tenant, response fields) → API → queue → worker (protected request metadata, execution-time permission) → Worker → private CSV → download API (current permission on the artifact) → API / worker → logs (allowed diagnostic fields only)
| Entry point | Allowed in this example | Counterexample to reject |
|---|---|---|
| Invoice read and list | A current tenant reader reads their company's invoices | A caller from A requests B's invoice ID |
| Invoice memo update | Tenant accounting staff update their company's memo | A reader writes; a caller changes tenant_id or the amount |
| CSV export and download | Staff with current export permission retrieve their company's data | An export revoked before execution; another tenant's artifact |
Checking only an administrator role misses tenant membership; checking only membership lets readers write. Give this table to implementers and testers. Cross-company administration needs an explicit authorization and audit path separate from ordinary access.
Prevent BOLA and IDOR: bind tenant and action checks to data access
A dangerous implementation checks login and looks up the invoice by ID alone. Verify membership and action permission, then constrain the data lookup by tenant too. Updates, deletes, lists, and bulk reads require the same boundary.
principal = authenticate(request)
tenant = requireCurrentMembership(principal, request.selectedTenant)
requirePermission(principal, tenant, "invoice:read")
invoice = invoices.forTenant(tenant).find(request.invoiceId)
if invoice is None:
return notFound()
return invoiceReadResponse(invoice)selectedTenant lets a user choose a company; the server verifies membership rather than trusting that input. Here, readers may read every invoice in their company. A product with project-specific access must add that relationship to the query scope. A tenant_id in a header or JWT also requires a verified origin and the freshness the policy needs.
SELECT id, number, total, currency, memo
FROM invoices
WHERE tenant_id = $1 AND id = $2;Require tenant scope in a common data-access layer to reduce forgotten filters. Never fall back to an unscoped query when authorization cannot be established. Define an error policy, such as returning 404 to conceal object existence, and check list counts and error bodies for cross-company disclosures.
Input validation: do not mass-assign tenant IDs or privileges
Valid JSON is insufficient for a memo update. Adding tenant_id or total to a body that is copied into a persistence model may change ownership or the amount. Only memo is writable here. Choose response fields in a dedicated model instead of automatically serializing internal columns or integration secrets. OWASP API3 addresses these object-property authorization failures.
input = validateMemoRequest(request.body)
# Exactly { memo: string }; reject unknown fields; enforce length limit.
principal = authenticate(request)
tenant = requireCurrentMembership(principal, request.selectedTenant)
requirePermission(principal, tenant, "invoice:memo:update")
updated = invoices.forTenant(tenant).updateMemo(request.invoiceId, input.memo)
if not updated:
return notFound()
return noContent()Validate types, required fields, lengths, and ranges on the server, with size limits before fully buffering the body. Syntactically correct JSON need not satisfy business rules. Bound SQL values and authorization for the target remain separate requirements.
Cache security: authorize hits and key by sharing scope
Suppose A and B each have a different invoice numbered 42. Database queries filter by company, but the cache uses invoice:42. After A warms it, B can receive A's value. Neither the database WHERE clause nor RLS runs on a cache hit.
tenant = requireCurrentMembership(principal, selectedTenant)
requirePermission(principal, tenant, "invoice:read")
key = encodeKey(["invoice-response-v1", tenant.id, invoiceId])
value = cache.get(key)
if value is None:
value = invoices.forTenant(tenant).find(invoiceId)
if value is None:
return notFound()
value = invoiceReadResponse(value)
cache.put(key, value, configuredTTL)
return valueThe encoder is assumed to encode tuples without collisions. If fields differ by user within a company, reflect that distinction in keys and response construction. Cached authorization decisions need their own staleness and revocation policy. A short response TTL alone does not guarantee rejection immediately after permission removal.
Async exports: check acceptance, execution, and download
Our requirement is explicit: removing permission before execution must stop pending CSV exports. Acceptance-time approval may be stale when a worker starts minutes later. The worker's database credentials do not establish the requester's export permission.
- Acceptance: verify requester, membership, export permission, and target. Store tamper-protected job metadata; an unchecked message tenant_id must not override it.
- Execution: accept jobs through a trusted queue path and recheck current requester permission and target tenant. Defer or deny on an unavailable check, with a defined resume policy.
- Download: store CSVs privately and check current access to the artifact. Knowing a job ID or filename is insufficient.
For long exports, define further checkpoints and disposal of partial files. A pre-execution check does not instantly reflect changes made afterward; specify the required revocation deadline. A workflow that commits a transaction at acceptance needs a separate decision about whether later revocation should cancel that committed work.
Log denials without duplicating secrets
Capturing full requests copies Authorization headers, cookies, signed URLs, and invoice bodies into another store. Record allowed structured fields and apply the same rules to access logs, exception collectors, and workers.
{"event":"invoice_read_denied","request_id":"r42","route":"/invoices/:id","status":404,"error_code":"resource_unavailable"}If actor or tenant identifiers are needed, define the purpose, add minimal audit fields, and control access and retention. Do not insert arbitrary exception messages or headers. Send dummy secrets through success, invalid-input, and downstream-failure paths and check collectors for leaks. Request IDs correlate records; they do not establish authorization.
Implementation checklist: test operations that must be rejected
Successful requests alone do not verify a boundary. In a test environment you control, create A and B's data plus a reader and an accountant in A. Inspect response bodies, database changes, artifacts, and logs alongside HTTP statuses.
| Attempt | Expected result | Inspect |
|---|---|---|
| A reads, updates, or deletes B's ID | No object contents or mutations | Individual and bulk APIs, list/search results and counts |
| A's reader updates a memo | Deny the action | Unchanged database memo |
| Add tenant_id or total to an update | Reject input under this contract | Unchanged ownership and amount |
| Warm invoice 42 as A, then fetch 42 as B | Return only B's value | Reverse order, misses, hits after revocation |
| Revoke permission between acceptance and execution | Do not run this export under our requirement | Job outcome and absence of a generated CSV |
| Fetch another tenant's artifact; revoke own download permission | Deny retrieval | All delivery paths, including direct URLs |
| Send dummy secrets through invalid-input and exception paths | Do not retain the secrets | API, proxy, worker, and exception collectors |
Also verify allowed operations so a deny-everything implementation cannot pass unnoticed. When adding search endpoints or export methods, update the boundary table and regression tests. Passing tests does not establish the safety of untested administrative paths or production configuration.
FAQ: are API keys, UUIDs, or a WAF enough?
- Is requiring an API key sufficient? Establish its identity and scope, then authorize the target action. Possession must not imply access to every company's invoices.
- Do UUIDs prevent IDOR? Known IDs still require access checks. Hiding links in the screen does not prevent direct API requests either.
- Can a WAF or input validation prevent authorization gaps? A caller with a correctly formatted ID may still need to be denied. Enforce authorization for membership, target, and action.
Primary sources for implementation decisions and tests
- OWASP API Security Top 10 2023 — API1: Broken Object Level Authorization
- OWASP — Threat Modeling Cheat Sheet
- OWASP — Authorization Cheat Sheet
- OWASP — Input Validation Cheat Sheet
- OWASP API Security Top 10 2023 — API3: Broken Object Property Level Authorization
- PostgreSQL 17 — Row Security Policies
- OWASP — Multi-Tenant Security: cache, asynchronous work, and storage
- OWASP — Logging Cheat Sheet
- OWASP Web Security Testing Guide — API Broken Object Level Authorization
Tips this post digs into
- Map protected data and trust boundaries before building (threat modeling)
- Hard-to-guess IDs are not a substitute for authorization
- Validate again at the API boundary, even when the form validates
- Tenant-separated databases can still leak through shared cache keys
- A queued job may outlive the permission that allowed it
- Keep tokens and complete request bodies out of diagnostic logs