Phil Karlton famously said there are only two hard things in Computer Science: cache invalidation and naming things. While naming conventions are frustrating, cache invalidation failures can have real security consequences.
The Security Problem with Caching
Caching improves performance by storing frequently accessed data. But when that data includes permissions or access control information, stale caches become security vulnerabilities.
A Common Scenario
Consider a multi-tenant SaaS application:
// User permissions are cached for performance
async function getUserPermissions(userId: string): Promise<Permissions> {
const cached = await cache.get(`permissions:${userId}`);
if (cached) return cached;
const permissions = await db.permissions.findByUserId(userId);
await cache.set(`permissions:${userId}`, permissions, { ttl: 300 }); // 5 minutes
return permissions;
}
Now imagine this scenario:
- Admin removes a user from a team at 10:00 AM
- User's permissions were cached at 9:58 AM
- User continues to access team data until 10:03 AM (cache expiry)
That's a 5-minute window where a revoked user still has access. In security-sensitive contexts, this is unacceptable.
Common Cache Invalidation Failures
1. Forgetting to Invalidate on Role Changes
// Vulnerable: No cache invalidation
async function removeUserFromTeam(userId: string, teamId: string) {
await db.teamMembers.delete({ userId, teamId });
// Cache still contains old permissions!
}
2. Inconsistent Cache Keys
// Different parts of the app use different cache keys
const key1 = `permissions:${userId}`;
const key2 = `user:${userId}:permissions`;
const key3 = `perms-${userId}`;
// Invalidating one doesn't invalidate the others
await cache.delete(`permissions:${userId}`);
// But key2 and key3 still have stale data!
3. Multi-Level Cache Inconsistency
// Application has multiple cache layers
const inMemoryCache = new Map();
const redisCache = new Redis();
// Invalidation only clears Redis
await redisCache.del(`permissions:${userId}`);
// In-memory cache on other servers still has old data!
Solutions
1. Event-Driven Invalidation
// Publish events when permissions change
eventBus.on('user.permissions.changed', async ({ userId }) => {
// Invalidate all cache keys for this user
await cache.delete(`permissions:${userId}`);
await cache.delete(`user:${userId}:*`); // Pattern-based deletion
});
async function removeUserFromTeam(userId: string, teamId: string) {
await db.teamMembers.delete({ userId, teamId });
eventBus.emit('user.permissions.changed', { userId });
}
2. Centralized Cache Key Management
// Single source of truth for cache keys
const CacheKeys = {
userPermissions: (userId: string) => `permissions:v1:${userId}`,
teamMembers: (teamId: string) => `team:v1:${teamId}:members`,
};
// Use everywhere
await cache.delete(CacheKeys.userPermissions(userId));
3. Cache Versioning
// Store a version number that increments on changes
async function getUserPermissions(userId: string): Promise<Permissions> {
const version = await db.users.getPermissionVersion(userId);
const cacheKey = `permissions:${userId}:v${version}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const permissions = await db.permissions.findByUserId(userId);
await cache.set(cacheKey, permissions, { ttl: 3600 }); // Longer TTL is safe now
return permissions;
}
async function updateUserPermissions(userId: string) {
await db.users.incrementPermissionVersion(userId);
// Old cache entries become orphaned and eventually expire
}
4. Short TTLs for Security-Critical Data
// Different TTLs based on data sensitivity
const TTL = {
userProfile: 3600, // 1 hour - low sensitivity
userPermissions: 60, // 1 minute - high sensitivity
sessionData: 0, // No caching - critical
};
5. Distributed Cache Invalidation
// Use pub/sub for multi-server invalidation
const pubsub = new Redis();
pubsub.subscribe('cache:invalidate', async (message) => {
const { pattern } = JSON.parse(message);
// Clear local in-memory cache
localCache.deletePattern(pattern);
});
async function invalidateCache(pattern: string) {
// Clear Redis
await redisCache.deletePattern(pattern);
// Notify all servers
await pubsub.publish('cache:invalidate', JSON.stringify({ pattern }));
}
Testing Cache Invalidation
describe('Permission Cache Invalidation', () => {
it('should immediately revoke access when user is removed from team', async () => {
// Setup: User is team member
const user = await createUser();
const team = await createTeam();
await addUserToTeam(user.id, team.id);
// Prime the cache
const permissions1 = await getUserPermissions(user.id);
expect(permissions1.teams).toContain(team.id);
// Remove user from team
await removeUserFromTeam(user.id, team.id);
// Permissions should be immediately updated
const permissions2 = await getUserPermissions(user.id);
expect(permissions2.teams).not.toContain(team.id);
});
});
How SecurityChecks Detects These Issues
Our analyzer identifies:
- Missing cache invalidation - Permission changes without corresponding cache updates
- Inconsistent invalidation - Patterns that invalidate some but not all related caches
- Long TTLs on security data - Permissions cached with TTLs over recommended thresholds
- Missing pub/sub in distributed systems - Local cache clears without notifying other nodes
Summary
Cache invalidation for security-sensitive data requires:
- Immediate invalidation on permission changes
- Centralized cache key management to ensure consistency
- Event-driven architecture to propagate changes
- Distributed invalidation for multi-server deployments
- Short TTLs as a safety net
Don't let stale caches become security holes. Audit your caching strategy today.