Outgoing webhooks are an often-overlooked data-egress channel. When a webhook payload is assembled from a broad database query — one not scoped to the triggering tenant — a tenant's webhook endpoint can receive data belonging to a different tenant. CWE-200 (Exposure of Sensitive Information) and OWASP A01 both apply. The business impact is compounded because webhook payloads are delivered to external URLs configured by tenants, meaning a data leak goes directly to a third party outside your control and cannot be retroactively revoked.
Medium because the data escapes to external systems controlled by tenants, making retrospective containment impossible once a webhook fires with cross-tenant data.
Scope all payload-construction queries to the triggering tenant and abort if the resource cannot be confirmed as belonging to that tenant:
async function dispatchWebhook(tenantId: string, event: string, resourceId: string) {
const resource = await db.resource.findFirst({
where: { id: resourceId, organizationId: tenantId } // ownership check in query
})
if (!resource) return // resource doesn't belong to this tenant — abort silently
const subscriptions = await db.webhookSubscriptions.findMany({
where: { organizationId: tenantId, events: { has: event } }
})
// dispatch to subscriptions...
}
Place this pattern in src/lib/webhooks.ts and ensure all include relations on the resource query belong to the same tenant. Never fetch related entities by a bare foreign key without re-validating their organizationId.
ID: saas-multi-tenancy.tenant-boundaries.webhook-payloads-no-other-tenant
Severity: medium
What to look for: Examine outgoing webhook dispatch code — places where the application calls external URLs configured by tenants (Zapier hooks, custom webhooks, notification endpoints). Check the payload construction logic: what data is included in the webhook body? Verify that the payload is constructed from the triggering tenant's data only. Also examine webhook event filtering to ensure a tenant's webhook subscription only receives events for that tenant's resources.
Pass criteria: Enumerate all webhook dispatch locations. Outgoing webhook payloads are constructed from the triggering tenant's data only with 0% data from other tenants. No related entities from other tenants are included. Webhook subscriptions are filtered to only fire for the tenant that created the subscription.
Fail criteria: Webhook payload construction includes a database query that doesn't scope to the triggering tenant, or the payload includes cross-tenant references. A webhook fired for Tenant A's event includes data from Tenant B's related records.
Skip (N/A) when: No outgoing webhook dispatch system is detected. Signal: no webhook subscription table/model, no outgoing HTTP call patterns triggered by application events, no webhook endpoint configuration in tenant settings.
Detail on fail: Describe what is included in the payload that shouldn't be. Example: "Webhook payload for 'project.updated' event in src/lib/webhooks.ts includes a full organization object fetched by ID from the project record, which could include another tenant's organization data if the project's org reference is corrupted."
Remediation: Scope payload construction queries to the known tenant:
async function dispatchWebhook(tenantId: string, event: string, resourceId: string) {
// Fetch payload data scoped to the known tenant
const resource = await db.resource.findFirst({
where: { id: resourceId, organizationId: tenantId }, // ownership check
include: { /* only include relations that belong to this tenant */ }
})
if (!resource) return // Resource doesn't belong to this tenant — abort
const subscriptions = await db.webhookSubscriptions.findMany({
where: { organizationId: tenantId, events: { has: event } }
})
// Send to subscriptions...
}