# Webhook payloads do not include other tenants' data

- **Pattern:** `ab-002326` (`saas-multi-tenancy.tenant-boundaries.webhook-payloads-no-other-tenant`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002326
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002326)

## Why it matters

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.

## Severity rationale

Medium because the data escapes to external systems controlled by tenants, making retrospective containment impossible once a webhook fires with cross-tenant data.

## Remediation

Scope all payload-construction queries to the triggering tenant and abort if the resource cannot be confirmed as belonging to that tenant:

```ts
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`.

## Detection

- **ID:** `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:

  ```ts
  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...
  }
  ```

---

## External references

- cwe CWE-285
- cwe CWE-200
- owasp A01

Taxons: access-control

HTML version: https://auditbuffet.com/patterns/ab-002326
