# Cache keys include the tenant identifier

- **Pattern:** `ab-002320` (`saas-multi-tenancy.data-isolation.cache-keys-include-tenant`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002320
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002320)

## Why it matters

CWE-524 (Use of Cache Containing Sensitive Information) combined with OWASP A01 describes exactly this failure: a cache key like `dashboard-stats` that is populated by Tenant A's first request and then returned unchanged to Tenant B. Unlike database-layer isolation gaps, cache leaks are silent — no error is raised, the data looks valid, and the victim tenant never knows they received another org's numbers. The window for cross-tenant exposure is bounded only by the cache TTL, which is often measured in minutes or hours.

## Severity rationale

High because a cache key collision silently serves one tenant's business data to another tenant with no error or signal that a leak has occurred.

## Remediation

Prefix every cache key for tenant-specific data with the tenant identifier. Apply this to Redis, Upstash, Next.js `unstable_cache`, and React Query/SWR cache keys:

```ts
// Instead of:
const cacheKey = `dashboard-stats`

// Do this:
const cacheKey = `tenant:${session.user.organizationId}:dashboard-stats`

// For Next.js unstable_cache:
const getData = unstable_cache(
  async () => fetchDashboardStats(orgId),
  [`org-${orgId}-dashboard-stats`],
  { tags: [`org-${orgId}`, 'dashboard-stats'] }
)

// Invalidate with tenant scope:
await revalidateTag(`org-${orgId}`)
```

Audit cache invalidation paths too — a key-scoped write is safe, but a global `flushAll` in a multi-tenant cache is a denial-of-service risk for other tenants.

## Detection

- **ID:** `cache-keys-include-tenant`
- **Severity:** `high`
- **What to look for:** Examine all caching code — Redis/Upstash calls, in-memory caches, React Query/SWR cache keys, Next.js `unstable_cache` calls, ISR cache tags. Check whether cache keys include a tenant identifier to prevent one tenant's cached data from being served to another tenant's request. Look for patterns like `cache.get('user-list')` with no tenant in the key vs. `cache.get(`tenant:${tenantId}:user-list`)`.
- **Pass criteria:** Count all cache set/get calls for tenant-specific data and confirm at least 100% include the tenant identifier as at least 1 component of the key. Cache invalidation also includes the tenant scope. Next.js cache tags include tenant identifiers when tagging tenant-specific data.
- **Fail criteria:** Any cache key for tenant-specific data that does not include the tenant identifier. A request from Tenant A could receive cached data that was generated for Tenant B's request.
- **Skip (N/A) when:** No caching layer is detected. Signal: no Redis, Upstash, ioredis, or cache-related dependencies; no `unstable_cache` usage; no in-memory cache patterns; no ISR revalidation patterns for dynamic tenant data.
- **Detail on fail:** Name the cache keys and what tenant data they contain. Example: `"Redis key 'dashboard-stats' used in src/lib/cache.ts does not include tenant identifier. This key is set from the first tenant's request and returned to subsequent requests from any tenant."`
- **Remediation:** Always scope cache keys to the tenant:

  ```ts
  // Instead of:
  const cacheKey = `dashboard-stats`

  // Do this:
  const cacheKey = `tenant:${session.user.organizationId}:dashboard-stats`

  // For Next.js unstable_cache:
  const getData = unstable_cache(
    async () => fetchDashboardStats(orgId),
    [`org-${orgId}-dashboard-stats`],
    { tags: [`org-${orgId}`, 'dashboard-stats'] }
  )

  // For cache invalidation, always include tenant scope:
  await revalidateTag(`org-${orgId}`)
  ```

---

## External references

- cwe CWE-524
- cwe CWE-285
- owasp A01

Taxons: access-control

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