Database Caching at Scale · Cache Consistency Strategies
Caching Personalized and Authorized Data Safely
Cache keys that forget the user, tenant, locale or permissions turn a performance feature into a data leak.
Scenario
Example company: a multi-tenant B2B SaaS caches API responses in Redis to speed up dashboards. Responses depend on the tenant, the user's role (admins see salaries, others don't) and the user's locale.
What you see in production
A security report: a regular employee saw salary columns in the team dashboard right after an admin had loaded it. Another tenant's customer briefly saw a competitor's project names. Both came from the cache.
The naive code
The cache key is derived from the URL or the resource id only:
def team_dashboard(team_id, user):
key = f"dashboard:{team_id}" # no tenant, role or locale
if (hit := r.get(key)) is not None:
return json.loads(hit)
data = build_dashboard(team_id, include_salaries=user.is_admin)
r.set(key, json.dumps(data), ex=300)
return data
@Cacheable(value = "dashboards", key = "#teamId") // ignores who is asking
public Dashboard team(long teamId, User user) { ... }
Reproduce it
An admin request fills the cache; then a regular user requests the same team dashboard:
def build(team, role):
data = {"team": team, "members": ["Ada", "Lin"]}
if role == "admin":
data["salaries"] = [150_000, 120_000]
return data
def handler(cache, key_fn, team, user, role):
key = key_fn(team, user, role)
if key not in cache:
cache[key] = build(team, role)
return "salaries" in cache[key]
for name, key_fn in (("key = team", lambda t, u, r: f"dash:{t}"),
("key = team + role", lambda t, u, r: f"dash:{t}:role={r}")):
cache = {}
handler(cache, key_fn, "t1", "boss", "admin")
leaked = handler(cache, key_fn, "t1", "intern", "member")
print(f"{name:18} intern sees salaries? {leaked}")
Output
key = team intern sees salaries? True key = team + role intern sees salaries? False
Root cause
A cache key must contain every input that changes the response. Authorization and personalization are inputs too: tenant, user or role, feature flags, locale, currency. Missing one means the first requester's view is served to everyone else with the same key. In multi-tenant systems, ids alone are not proof of tenancy.
The fix
- Build keys from an explicit "response depends on" list:
tenant,resource,role/permission set,locale, schema version. - Cache permission-independent data and apply authorization after reading from the cache (filter fields per user).
- Never cache responses containing secrets or tokens; prefer per-user keys only where reuse is high.
def team_dashboard(team_id, user):
base_key = f"v2:t{user.tenant_id}:dashboard:{team_id}:{user.locale}"
data = cache_get_or_set(base_key, lambda: build_dashboard(team_id, user.tenant_id, include_salaries=True))
if data["tenant_id"] != user.tenant_id:
raise PermissionDenied() # defence in depth
return redact(data, user.permissions) # apply authorization after the cache
@Cacheable(value = "dashboards", key = "#user.tenantId + ':' + #teamId + ':' + #user.roleSetHash + ':' + #user.locale")
public Dashboard team(long teamId, User user) { ... }
$key = sprintf('v2:t%d:dashboard:%d:%s:%s', $user->tenant_id, $teamId, $user->roleHash(), app()->getLocale());
$data = Cache::remember($key, 300, fn () => $this->build($teamId, $user));
const key = `v2:t${user.tenantId}:dashboard:${teamId}:${user.locale}`;
const data = await cached(key, () => buildDashboard(teamId, user.tenantId)); // full data, tenant-scoped
return redact(data, user.permissions); // authorize after cache
Trade-offs
- Adding user-specific parts to keys lowers the hit ratio; caching shared data and authorizing afterwards keeps reuse high.
- Redaction after the cache must be exhaustive — test it like any other authorization code.
- Separate Redis databases/prefixes per tenant help operations (flush one tenant) but are not a security boundary by themselves.
Detect it in production
- Tests that request the same resource as users with different roles/tenants and assert different responses.
- Code review checklist for every new cache: "what inputs change this response?"
- Audit logs including the cache key used for sensitive responses.
Key points
- Everything that changes a response must be in its cache key — including tenant, role and locale.
- Prefer caching permission-independent data and authorizing after reading.
- Test caches for cross-user and cross-tenant leaks.
Practice questions
A non-admin saw salary data after an admin loaded the same dashboard. What caused it?
Answer
The cache key didn't include the user's role (or permissions), so the admin's response was cached and served to anyone requesting the same dashboard.
What's a safer pattern than adding the user id to every cache key?
Answer
Cache permission-independent data (scoped by tenant) and apply authorization/redaction after reading from the cache, keeping reuse high without leaking.
Which inputs commonly belong in an API response cache key?
Answer
Tenant, resource id, role or permission set (if the response differs), locale/currency, relevant feature flags and a schema version.
Open in the app to mark lessons complete, take notes and track your progress.