Hi people,

This story started with a feature that looked harmless: select some private files and ask the application to prepare a downloadable archive.

I had two completely separate controlled tenants. Tenant B owned one synthetic image. Tenant A had no membership, role, or shared object in Tenant B. That setup gave me a clean question: would every path enforce the same tenant boundary?

Publication note: This story is reconstructed from private research. The organization, product, routes, identifiers, dates, and UI labels were removed or replaced. Every example below is synthetic. I am not claiming a deployed fix, completed retest, or program acceptance.

A background conveyor carrying a document across an otherwise blocked tenant boundary.
Original manga-style editorial illustration for this anonymized field note.

First, I proved the boundary worked

As Tenant B, I uploaded a small image containing a unique marker and exported it through the normal interface. That owner-positive archive gave me the expected filename, byte length, and SHA-256 hash.

Then I switched to Tenant A and requested Tenant B's object through the direct read endpoint. The server denied it. That was good. It also gave me a strong baseline: the application knew Tenant A did not own the object.

If I had stopped there, the authorization would have looked correct.

The reconstructed control matrix: one principal and one foreign object, with different authorization decisions on two paths.

The interesting path was asynchronous

The export endpoint did not return the files immediately. It accepted object references, queued a job, and later exposed a completed archive. That split is exactly where I like to test again.

Using Tenant A's session, I submitted the synthetic reference owned by Tenant B. Instead of rejecting the request, the API created an export job. When the worker finished, Tenant A could download an archive.

GET /objects/[TENANT_B_OBJECT] HTTP/1.1
Host: app.research.example
Authorization: Bearer [REDACTED]

HTTP/1.1 403 Forbidden

POST /exports HTTP/1.1
Host: app.research.example
Authorization: Bearer [REDACTED]
Content-Type: application/json

{"objects":["[TENANT_B_OBJECT]"]}

HTTP/1.1 202 Accepted
{"job":"[SYNTHETIC_JOB]"}

This is a synthetic comparison. The host, paths, status body, field names, and identifiers are placeholders rather than the original traffic.

A ZIP response was not enough proof

An archive with a plausible filename could still be empty, transformed, or unrelated. I extracted the file and compared it with Tenant B's owner-positive export.

The content length matched. More importantly, the SHA-256 hashes matched byte for byte. The marker inside the synthetic image was also the one I had created as Tenant B. The worker had not produced a generic preview; it had packaged the foreign tenant's original controlled content.

I stopped after that single file. I did not enumerate identifiers or request anyone else's data. The direct denial, the foreign export, and the hash comparison were enough to establish the boundary failure.

How authorization disappeared

The request handler appeared to treat the submitted reference as a valid global storage key. The background worker later resolved that key without carrying Tenant A's authorized object set into the job.

This pattern shows up in exports, report generation, document conversion, notifications, and scheduled actions. The front door can perform a correct check while the worker runs with broad service authority and trusts whatever identifiers were placed on the queue.

A mixed batch is especially useful during testing. Put one owned reference beside one foreign reference and check whether the application rejects the complete job, silently drops the foreign object, or packages both. Partial success can hide the same missing check.

Replacing sequential identifiers with random ones may make guessing harder, but it does not repair the missing authorization decision. A reference is not proof that the caller may use the object.

Open-source workflow companion: AuthMatrix

A useful open-source companion for this test is Caido AuthMatrix. It lays requests out against multiple controlled users and roles, which makes inconsistent authorization decisions easier to see as a grid instead of a pile of tabs. I would configure one identity for each test tenant, add both the ordinary object read and export-creation request, then follow the resulting job with the same controlled sessions. The important manual step remains opening the finished archive and matching its harmless marker to the owner-controlled source object; a successful response code alone is not evidence. The matrix helps reveal where the synchronous path and worker path disagree. This is a workflow recommendation for authorized testing, not a claim that AuthMatrix was used in the original private submission.

Takeaway

The repair belongs in both halves of the workflow:

  • Resolve every requested object through a tenant-scoped authorization query.
  • Reject mixed batches if even one object is outside the caller's access set.
  • Record the initiating principal and authorized objects on the job.
  • Revalidate authorization when the worker reads the files.
  • Bind completed downloads to the requesting user and a short lifetime.

My biggest lesson was simple: do not test only the API route that displays an object. Follow the same identifier into exports, workers, previews, and every delayed path that touches the data later.

That is all for this write-up. Hope you found the worker-side angle useful.

For another story about authorization on a different operation, read The draft you could change but could not read, or browse the full Bug Bounty Archive.

Test beyond the request

Authorization must survive the queue.

Balhence follows object references through APIs, workers, exports, and tenant boundaries.

Research topics: broken access control / multi-tenant security / background jobs / exports