Background jobs that query data without a tenant scope pull every tenant's records into a single job execution — a cross-tenant data commingling event that CWE-285 and OWASP A01 both flag. The practical impact: a report generation job produces a file containing all tenants' data, an email campaign job sends emails using the wrong tenant's template, or a cleanup job deletes records it shouldn't. Because jobs run asynchronously with no user context enforcing isolation, the error is often invisible until a tenant reports receiving data that isn't theirs.
Medium because the exposure is indirect — no user can directly trigger cross-tenant data access — but jobs can silently commingle data across all tenants in a single execution.
Queue one job per tenant, not one job for all tenants. Pass the tenant identifier explicitly in the job payload and use it for every query inside the handler:
// Fan-out: queue one job per tenant
const tenants = await db.organizations.findMany({ select: { id: true } })
for (const tenant of tenants) {
await queue.add('generate-report', { organizationId: tenant.id })
}
// Job handler — use ONLY the payload's organizationId
async function handleGenerateReport(payload: { organizationId: string }) {
const docs = await db.documents.findMany({
where: { organizationId: payload.organizationId }
})
}
In src/jobs/ handlers, treat the absence of organizationId in the payload as a fatal error — do not fall through to a broad query.
ID: saas-multi-tenancy.tenant-boundaries.background-jobs-tenant-scoped
Severity: medium
What to look for: Examine background job definitions, cron handlers, queue worker files (BullMQ, Inngest, Trigger.dev, Quirrel, cron endpoints). For jobs that process tenant data (sending emails, generating reports, syncing data, cleanup tasks), verify that each job invocation is scoped to a single tenant and that the job payload includes a tenant identifier that is used when querying data. Look for fan-out patterns — a cron job that iterates all tenants should properly scope each iteration.
Pass criteria: Count all background job handlers. Every background job that touches tenant-specific data: (a) receives at least 1 tenant identifier as part of its payload, (b) uses that identifier to scope all database queries within the job, and (c) does not cross-query other tenants' data during execution. Fan-out jobs that process all tenants iterate them explicitly and process each in isolated scope.
Fail criteria: Any background job that queries tenant data without a tenant scope in the query (pulling all records regardless of tenant). Any job where tenant context is assumed from a previous job's execution rather than explicitly provided in the payload.
Skip (N/A) when: No background job system is detected. Signal: no cron route handlers, no BullMQ/Inngest/Trigger.dev/Quirrel dependencies, no scheduled task configuration, no queue worker files.
Detail on fail: Describe the unscoped job. Example: "Daily report generation job in src/jobs/reports.ts queries all documents without tenantId filter, then groups them by tenant in application code — this means all tenant data is loaded into memory together."
Remediation: Pass tenant context explicitly in job payloads:
// Queue one job per tenant, not one job for all tenants
const tenants = await db.organizations.findMany({ select: { id: true } })
for (const tenant of tenants) {
await queue.add('generate-report', { organizationId: tenant.id })
}
// In the job handler — use only the payload's organizationId for queries
async function handleGenerateReport(payload: { organizationId: string }) {
const docs = await db.documents.findMany({
where: { organizationId: payload.organizationId }
})
}