All posts

API security: prevent BOLA and authorization gaps across databases, caches, and jobs

· 10 min read

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.

Main paths in the fictional invoicing SaaS; parentheses identify boundary checks

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 pointAllowed in this exampleCounterexample to reject
Invoice read and listA current tenant reader reads their company's invoicesA caller from A requests B's invoice ID
Invoice memo updateTenant accounting staff update their company's memoA reader writes; a caller changes tenant_id or the amount
CSV export and downloadStaff with current export permission retrieve their company's dataAn 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.

Pseudocode: verify membership, then require tenant-scoped data access
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.

PostgreSQL 17 SQL for that lookup; $1 and $2 are bound values
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.

Pseudocode with unknown-field rejection; updates also require tenant scope
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.

Pseudocode: tenant readers share this response; authorize before cache access
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 value

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

  1. Acceptance: verify requester, membership, export permission, and target. Store tamper-protected job metadata; an unchecked message tenant_id must not override it.
  2. 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.
  3. 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.

Illustrative diagnostic event with fixed codes and format-controlled identifiers
{"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.

AttemptExpected resultInspect
A reads, updates, or deletes B's IDNo object contents or mutationsIndividual and bulk APIs, list/search results and counts
A's reader updates a memoDeny the actionUnchanged database memo
Add tenant_id or total to an updateReject input under this contractUnchanged ownership and amount
Warm invoice 42 as A, then fetch 42 as BReturn only B's valueReverse order, misses, hits after revocation
Revoke permission between acceptance and executionDo not run this export under our requirementJob outcome and absence of a generated CSV
Fetch another tenant's artifact; revoke own download permissionDeny retrievalAll delivery paths, including direct URLs
Send dummy secrets through invalid-input and exception pathsDo not retain the secretsAPI, 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?

Primary sources for implementation decisions and tests

Tips this post digs into