Treat every background job as a new request. Bind the tenant to the message through a trusted channel, have the worker rebuild tenant context and authorize the operation and target, and recheck permission if the job runs late. A shared queue is not a tenant boundary on its own.
Why jobs are a blind spot
A web request carries a session and a verified tenant. By the time a job runs in a worker, that context is gone unless you carried it. Developers then write the worker with elevated database access "because it is internal", and a check that existed in the handler never happens.
The OWASP Multi-Tenant Security Cheat Sheet asks teams to classify asynchronous work as global, tenant-scoped or explicitly cross-tenant, and states that a shared queue or topic is not itself a tenant-isolation boundary.
What the message should carry
For tenant-scoped work, the cheat sheet says to derive tenant context from the authenticated producer and bind it to the message through trusted broker routing, authenticated metadata or an integrity-protected payload. Do not let an unverified message field override the producer's authorized scope.
| Message field | Source | Trust |
|---|---|---|
| Tenant ID | Set by the producer from verified request context | Trusted only if the channel is authenticated or the payload is integrity protected |
| Acting user ID | Same | Same |
| Target object ID | From the request | Must be re-authorized in the worker |
| Free text payload | From the client | Untrusted input |
Worker steps
- Authenticate the producer or broker path. Anyone who can write to the queue can otherwise enqueue a job for any tenant.
- Re-establish tenant context in the worker before touching data. If you use database row-level security, set the tenant inside the job's transaction.
- Authorize the operation and the target resource, not just the tenant.
- Recheck time-sensitive access. The sheet says to re-check membership or permission when delayed execution could make the original decision stale.
- Use a least-privileged database role for tenant jobs. Reserve a privileged connection for jobs that are explicitly cross-tenant.
Cross-tenant and global jobs
Some jobs, like billing runs or index rebuilds, legitimately span tenants. Give them an explicit identity and scopes, not a fabricated tenant. A job that processes all tenants should list which ones, and log them.
Other things that must be tenant aware
The cheat sheet adds more items to scope per tenant when their data or effects vary:
- Idempotency and deduplication keys, so one tenant's key cannot suppress another's job.
- Retry state and dead-letter queue access, since failed messages contain data.
- Per-tenant ordering where it matters.
- Queue depth, concurrency and throughput limits, so one tenant's burst does not starve the rest. Keep service-wide limits too.
A scenario to test
Enqueue an export job as tenant A. Remove the requesting user from the tenant before the worker runs. Expect the job to refuse. Then enqueue a message by hand with tenant B's ID but tenant A's producer credentials. Expect it to be rejected, since the producer is not authorized for that tenant.
Also read your dead-letter queue access list. Failed messages often contain full payloads and get broad read access.
Try it yourself
Click through it at your own pace, or open it full screen. Look at how checks keep running after deploy, which is the habit workers need for permission rechecks.
Safeguard provides SBOM and dependency scanning, so advisories in queue clients and worker frameworks are visible in your inventory. Try the free scan at gold.safeguard.sh/scan.
Quick check
Should the worker trust the tenant ID in the message?
Only if it came through an authenticated producer path or an integrity-protected payload. An unverified field must not widen scope.
Do scheduled jobs need a tenant?
If they act on one tenant's data, yes. If they span tenants, use an explicit cross-tenant identity with least privilege and log which tenants were touched.
Sources
Last reviewed: 19 October 2026.