Sourcing tenant context from client-supplied values — a request body tenantId, a query parameter, or an unverified path segment — is CWE-639 and OWASP A04 (Insecure Design) in combination: any user who can forge or swap that value gains access to an arbitrary tenant's data. This is not a theoretical risk; it is one of the most common multi-tenancy bugs because the attack requires only changing a single field in a normal API call. NIST AC-3 requires that access decisions be based on authoritative session state, not client assertions.
Critical because any authenticated user can swap a single request parameter to impersonate a different tenant and read or write their data.
Resolve tenant identity exclusively from the server-side session. If a tenant ID appears in the URL (e.g., /api/orgs/:orgId/), treat it as an untrusted assertion and cross-check it against the authenticated user's memberships before using it:
// src/middleware/tenantAuth.ts
const session = await getServerSession()
if (!session) return new Response('Unauthorized', { status: 401 })
const { orgId } = params
const membership = await db.memberships.findFirst({
where: { userId: session.user.id, organizationId: orgId }
})
if (!membership) return new Response('Forbidden', { status: 403 })
// Safe to use orgId now — membership is confirmed
Never pass tenantId from request bodies into database where clauses without this validation step. Centralize the membership check in middleware so no route can accidentally skip it.
ID: saas-multi-tenancy.data-isolation.tenant-context-from-session
Severity: critical
What to look for: Examine how tenant identity is established in each API route handler and middleware. Look for whether the tenant identifier (org ID, team ID, workspace ID) comes from: (a) the authenticated session/JWT (safe), or (b) a request body field, query parameter, or path parameter without session validation (unsafe). Check middleware for tenant resolution logic. Look for patterns like const tenantId = req.body.tenantId or const tenantId = searchParams.get('tenantId') used directly as a database filter without verification against the session.
Pass criteria: List all route handlers that resolve a tenant context and confirm at least 100% source the tenant identifier exclusively from the authenticated session (JWT claims, session object, or a server-side lookup). Quote the actual session resolution code or middleware name of the authenticated user's tenant). On pass, report the ratio of session-sourced tenant resolutions to total resolutions. Path parameters like /api/orgs/:orgId/ may appear in URLs, but the handler must verify that the authenticated user is a member of that org — the path parameter alone is not trusted authority.
Fail criteria: Any route handler that reads a tenant identifier from the request body, query string, or headers and uses it directly in a database query must not pass, query string, or headers and uses it directly in a database query without cross-checking it against the authenticated user's allowed tenants. Any route that accepts teamId in a POST body and uses it as a filter without verifying the authenticated user belongs to that team.
Skip (N/A) when: No authentication system is detected. Signal: no next-auth, clerk, lucia, supabase auth, firebase auth, auth0, or similar auth dependencies.
Detail on fail: Name the routes and parameters involved. Example: "POST /api/data/export reads tenantId from req.body.tenantId and uses it directly in database query without checking session membership in src/app/api/data/export/route.ts"
Cross-reference: Compare with saas-multi-tenancy.data-isolation.every-query-tenant-scoped — this check ensures tenant context comes from a trusted source; the query-scoping check ensures it is applied consistently.
Remediation: Always resolve tenant context from the server-side session, never from client-provided values. Treat any tenant identifier in a URL or request body as an untrusted assertion to be validated:
// Middleware or route handler
const session = await getServerSession()
if (!session) return new Response('Unauthorized', { status: 401 })
// If tenantId appears in URL params, validate membership
const { orgId } = params
const membership = await db.memberships.findFirst({
where: { userId: session.user.id, organizationId: orgId }
})
if (!membership) return new Response('Forbidden', { status: 403 })
// Now use orgId from the validated path param (or use session.user.organizationId directly)