CWE-285 (Improper Authorization) and OWASP A01 describe exactly this failure: an API that returns records without verifying the requester owns them. For list endpoints, a missing tenant filter exposes every record in the table to every authenticated user. For single-resource endpoints, guessing or enumerating a UUID is enough to retrieve another tenant's document, project, or user record — no special privilege required. This constitutes unauthorized disclosure of confidential business data and, where PII is involved, a reportable data breach under GDPR Art. 33.
Critical because unauthenticated enumeration of resource IDs — or simply calling a list endpoint — exposes every tenant's data to any valid session token.
For single-resource lookups, always fetch by both id and organizationId together so the database enforces ownership at query time rather than after the fact:
// Instead of:
const doc = await db.documents.findUnique({ where: { id: params.id } })
// Do this:
const doc = await db.documents.findUnique({
where: { id: params.id, organizationId: session.user.organizationId }
})
if (!doc) return new Response('Not found', { status: 404 })
For list endpoints, verify the underlying database query already has tenant scope applied (see every-query-tenant-scoped) — never filter in application memory after a broad fetch. Return 404 (not 403) on ownership mismatch to avoid confirming that the resource exists.
ID: saas-multi-tenancy.data-isolation.no-cross-tenant-api-responses
Severity: critical
What to look for: Examine all API route handlers that return lists of resources (GET /api/documents, GET /api/users, GET /api/projects, etc.). Verify that every list endpoint either: (a) queries with a tenant scope already applied at the database level, or (b) explicitly filters the result set by the authenticated user's tenant before returning. Also check single-resource endpoints (GET /api/documents/:id) to ensure they verify the resource belongs to the requesting tenant before returning it.
Pass criteria: Enumerate all API list endpoints and confirm at least 100% retrieve only records belonging to the current tenant. Every single-resource endpoint verifies tenant ownership before returning the resource (not just checking that the resource exists, but that it belongs to the requester's tenant). A request for a resource owned by a different tenant must not pass unless it returns 403 or 404 within no more than 500 milliseconds, not the resource.
Fail criteria: Any API endpoint that returns records from multiple tenants in a single response. An endpoint with a tenant filter in the query but not enforced at the database layer does not count as pass. Any single-resource endpoint that returns a resource without verifying the requesting user's tenant owns it. Any endpoint that accepts a tenantId in the request body or query string and uses that value without validating it matches the authenticated session's tenant.
Skip (N/A) when: No API routes are detected. Signal: no app/api/ directory, no pages/api/ directory, no serverless function files, no tRPC or GraphQL server setup.
Detail on fail: Name the specific endpoints and what they expose. Example: "GET /api/projects returns all projects regardless of tenant. GET /api/documents/:id returns document for any tenant if ID is known — no ownership check found in src/app/api/documents/[id]/route.ts"
Cross-reference: Compare with saas-multi-tenancy.tenant-boundaries.cross-tenant-search-blocked — API responses (this check) and search results (cross-tenant-search) both need tenant scoping, but this check focuses on direct API access.
Remediation: For list endpoints, the safest fix is to ensure the database query includes the tenant scope (see the every-query-tenant-scoped check). For single-resource lookups, always fetch by both id AND tenantId together:
// Instead of:
const doc = await db.documents.findUnique({ where: { id: params.id } })
// Do this:
const doc = await db.documents.findUnique({
where: { id: params.id, organizationId: session.user.organizationId }
})
if (!doc) return new Response('Not found', { status: 404 })
Never fetch a resource then check ownership after — fetch with ownership as part of the query predicate so the database returns nothing if there's a mismatch.