How to Immediately Invalidate IMemoryCache When Revoking Connected Devices to Prevent a 2-Minute Access Window?
I’m building a DeviceService to track connected devices for an account. I’ve implemented IMemoryCache to reduce database hits, setting a valid status cache for two minutes. However, this creates an issue: when revoking a connected device, it remains accessible for up to two minutes after revocation until the next request.
I understand this approach reduces database load, but it gives revoked devices a 2-minute window where they can still access the account. What is the proper way to handle this scenario? Is my current approach correct, or is there a better method for immediate cache invalidation when revoking devices?
Immediate invalidation of IMemoryCache when revoking connected devices can be achieved using cache entry eviction callbacks, distributed cache patterns, or event-driven notification systems. The two-minute access window can be eliminated by implementing active cache invalidation rather than relying solely on passive expiration, ensuring revoked devices lose access immediately upon revocation.
Contents
- Understanding the Current Cache Implementation
- Immediate Cache Invalidation Strategies
- Implementing Event-Driven Cache Invalidation
- Distributed Cache Patterns for Multi-Server Environments
- Best Practices and Performance Considerations
- Complete Implementation Example
- Alternative Approaches and Trade-offs
Understanding the Current Cache Implementation
Your current approach using IMemoryCache with a two-minute expiration is common for reducing database load, but as you’ve discovered, it creates a security risk during device revocation. The memory cache in ASP.NET Core works by storing data with expiration times, but it doesn’t provide immediate invalidation capabilities out of the box.
The typical implementation might look like this:
public class DeviceService
{
private readonly IMemoryCache _cache;
private readonly IDeviceRepository _deviceRepository;
public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository)
{
_cache = cache;
_deviceRepository = deviceRepository;
}
public async Task<bool> IsDeviceValidAsync(string deviceId)
{
string cacheKey = $"device_status_{deviceId}";
if (_cache.TryGetValue(cacheKey, out bool isValid))
{
return isValid;
}
isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
_cache.Set(cacheKey, isValid, TimeSpan.FromMinutes(2));
return isValid;
}
}
This approach works well for performance but leaves the 2-minute vulnerability you described. The key issue is that the cache entry remains valid until either:
- The 2-minute expiration elapses, or
- The same device makes another request (refreshing the cache)
Immediate Cache Invalidation Strategies
1. Using Cache Entry Eviction Callbacks
The most straightforward approach is to use memory cache callbacks to track active cache entries and invalidate them manually. This provides immediate control over cache entries.
public class DeviceService
{
private readonly IMemoryCache _cache;
private readonly IDeviceRepository _deviceRepository;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _cacheEntries;
public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository)
{
_cache = cache;
_deviceRepository = deviceRepository;
_cacheEntries = new ConcurrentDictionary<string, CancellationTokenSource>();
}
public async Task<bool> IsDeviceValidAsync(string deviceId)
{
string cacheKey = $"device_status_{deviceId}";
if (_cache.TryGetValue(cacheKey, out bool isValid))
{
return isValid;
}
var cts = new CancellationTokenSource();
_cacheEntries.TryAdd(cacheKey, cts);
isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
_cache.Set(cacheKey, isValid,
new MemoryCacheEntryOptions
{
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = RemoveCacheEntry,
State = cacheKey
}
}
});
return isValid;
}
public void RevokeDevice(string deviceId)
{
string cacheKey = $"device_status_{deviceId}";
if (_cacheEntries.TryRemove(cacheKey, out var cts))
{
cts.Cancel();
_cache.Remove(cacheKey);
}
}
private void RemoveCacheEntry(object key, object value, EvictionReason reason, object state)
{
if (state is string cacheKey && _cacheEntries.TryRemove(cacheKey, out var cts))
{
cts.Dispose();
}
}
}
2. Using MemoryCacheEntryOptions with Expiration Tokens
Another approach is to use IChangeToken to create custom expiration mechanisms:
public class DeviceRevocationToken : IChangeToken
{
private readonly CancellationTokenSource _cts;
public DeviceRevocationToken()
{
_cts = new CancellationTokenSource();
}
public bool ActiveChangeCallbacks => true;
public bool HasChanged => false; // We control this manually
public IDisposable RegisterChangeCallback(Action<object> callback, object state)
{
return _cts.Token.Register(callback, state);
}
public void TriggerRevocation()
{
_cts.Cancel();
}
public void Dispose()
{
_cts.Dispose();
}
}
public class DeviceService
{
private readonly IMemoryCache _cache;
private readonly IDeviceRepository _deviceRepository;
public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository)
{
_cache = cache;
_deviceRepository = deviceRepository;
}
public async Task<bool> IsDeviceValidAsync(string deviceId)
{
string cacheKey = $"device_status_{deviceId}";
if (_cache.TryGetValue(cacheKey, out bool isValid))
{
return isValid;
}
var revocationToken = new DeviceRevocationToken();
isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
_cache.Set(cacheKey, isValid,
new MemoryCacheEntryOptions
{
ExpirationTokens = { revocationToken }
});
return isValid;
}
public void RevokeDevice(string deviceId)
{
string cacheKey = $"device_status_{deviceId}";
_cache.Remove(cacheKey);
}
}
Implementing Event-Driven Cache Invalidation
For more sophisticated scenarios, consider implementing an event-driven approach where device revocations trigger cache invalidation across the application:
1. Using MediatR or Event Bus Pattern
public class DeviceRevokedEvent : INotification
{
public string DeviceId { get; }
public DateTime RevokedAt { get; }
public DeviceRevokedEvent(string deviceId)
{
DeviceId = deviceId;
RevokedAt = DateTime.UtcNow;
}
}
public class DeviceRevocationHandler : INotificationHandler<DeviceRevokedEvent>
{
private readonly IMemoryCache _cache;
public DeviceRevocationHandler(IMemoryCache cache)
{
_cache = cache;
}
public Task Handle(DeviceRevokedEvent notification, CancellationToken cancellationToken)
{
string cacheKey = $"device_status_{notification.DeviceId}";
_cache.Remove(cacheKey);
return Task.CompletedTask;
}
}
public class DeviceService
{
private readonly IMemoryCache _cache;
private readonly IDeviceRepository _deviceRepository;
private readonly IServiceProvider _serviceProvider;
public DeviceService(IMemoryCache cache, IDeviceRepository deviceRepository, IServiceProvider serviceProvider)
{
_cache = cache;
_deviceRepository = deviceRepository;
_serviceProvider = serviceProvider;
}
public async Task<bool> IsDeviceValidAsync(string deviceId)
{
string cacheKey = $"device_status_{deviceId}";
if (_cache.TryGetValue(cacheKey, out bool isValid))
{
return isValid;
}
isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
_cache.Set(cacheKey, isValid, TimeSpan.FromMinutes(2));
return isValid;
}
public async Task RevokeDeviceAsync(string deviceId)
{
// Revoke in database first
await _deviceRepository.RevokeDeviceAsync(deviceId);
// Trigger cache invalidation
using var scope = _serviceProvider.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Publish(new DeviceRevokedEvent(deviceId));
}
}
2. Background Service for Cache Synchronization
If you need to handle cache invalidation across multiple instances:
public class CacheInvalidationBackgroundService : BackgroundService
{
private readonly IMemoryCache _cache;
private readonly ICacheInvalidationQueue _queue;
public CacheInvalidationBackgroundService(IMemoryCache cache, ICacheInvalidationQueue queue)
{
_cache = cache;
_queue = queue;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var invalidationRequest = await _queue.DequeueAsync(stoppingToken);
_cache.Remove(invalidationRequest.CacheKey);
}
}
}
public interface ICacheInvalidationQueue
{
Task EnqueueAsync(string cacheKey);
Task<string> DequeueAsync(CancellationToken cancellationToken);
}
public interface ICacheInvalidationPublisher
{
Task PublishInvalidationAsync(string cacheKey);
}
public class RedisCacheInvalidationPublisher : ICacheInvalidationPublisher
{
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _database;
private readonly string _channel = "cache_invalidation";
public RedisCacheInvalidationPublisher(IConnectionMultiplexer redis)
{
_redis = redis;
_database = redis.GetDatabase();
}
public async Task PublishInvalidationAsync(string cacheKey)
{
await _database.PublishAsync(_channel, cacheKey);
}
}
Distributed Cache Patterns for Multi-Server Environments
When running multiple server instances, you need a distributed approach to cache invalidation:
1. Using Redis for Distributed Cache Invalidation
public class RedisCacheInvalidationSubscriber : BackgroundService
{
private readonly IMemoryCache _memoryCache;
private readonly IConnectionMultiplexer _redis;
private readonly string _channel = "cache_invalidation";
public RedisCacheInvalidationSubscriber(IMemoryCache memoryCache, IConnectionMultiplexer redis)
{
_memoryCache = memoryCache;
_redis = redis;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var subscriber = _redis.GetSubscriber();
await subscriber.SubscribeAsync(_channel, (channel, message) =>
{
if (message.HasValue)
{
string cacheKey = message.ToString();
_memoryCache.Remove(cacheKey);
}
}, CommandFlags.FireAndForget);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
// In Startup.cs:
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
services.AddSingleton<IHostedService, RedisCacheInvalidationSubscriber>();
2. Using SQL Server for Cache Invalidation
public class SqlCacheInvalidationService : BackgroundService
{
private readonly IMemoryCache _memoryCache;
private readonly IConfiguration _configuration;
private Timer _timer;
public SqlCacheInvalidationService(IMemoryCache memoryCache, IConfiguration configuration)
{
_memoryCache = memoryCache;
_configuration = configuration;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
_timer = new Timer(CheckForInvalidations, null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
return Task.CompletedTask;
}
private void CheckForInvalidations(object state)
{
try
{
using var connection = new SqlConnection(_configuration.GetConnectionString("DefaultConnection"));
connection.Open();
using var command = new SqlCommand(
"SELECT CacheKey FROM CacheInvalidations WHERE Processed = 0", connection);
using var reader = command.ExecuteReader();
while (reader.Read())
{
string cacheKey = reader["CacheKey"].ToString();
_memoryCache.Remove(cacheKey);
}
// Mark invalidations as processed
command.CommandText = "UPDATE CacheInvalidations SET Processed = 1 WHERE Processed = 0";
command.ExecuteNonQuery();
}
catch (Exception ex)
{
// Log error
}
}
public override void Dispose()
{
_timer?.Dispose();
base.Dispose();
}
}
Best Practices and Performance Considerations
Cache Key Management
- Use consistent, descriptive cache keys
- Consider user/account context in cache keys
- Implement proper key naming conventions
Memory Management
- Monitor cache memory usage
- Implement cache size limits
- Consider using
MemoryCacheEntryOptions.SizeLimitfor large objects
Concurrency Considerations
- Use thread-safe collections for tracking cache entries
- Implement proper locking mechanisms when needed
- Consider using
ConcurrentDictionaryfor cache entry tracking
Performance Optimization
- Minimize cache invalidation overhead
- Batch cache invalidations when possible
- Consider using async/await for cache operations
Security Considerations
- Ensure cache invalidation cannot be triggered by unauthorized users
- Implement proper access controls for device revocation
- Consider audit logging for cache invalidation events
Complete Implementation Example
Here’s a complete, production-ready implementation that combines several of the strategies discussed:
public interface IDeviceCacheService
{
Task<bool> IsDeviceValidAsync(string deviceId);
Task RevokeDeviceAsync(string deviceId);
Task RevokeAllDevicesForAccountAsync(string accountId);
}
public class DeviceCacheService : IDeviceCacheService
{
private readonly IMemoryCache _cache;
private readonly IDeviceRepository _deviceRepository;
private readonly ICacheInvalidationPublisher _invalidationPublisher;
private readonly ILogger<DeviceCacheService> _logger;
private readonly ConcurrentDictionary<string, DeviceCacheEntry> _activeEntries;
public DeviceCacheService(
IMemoryCache cache,
IDeviceRepository deviceRepository,
ICacheInvalidationPublisher invalidationPublisher,
ILogger<DeviceCacheService> logger)
{
_cache = cache;
_deviceRepository = deviceRepository;
_invalidationPublisher = invalidationPublisher;
_logger = logger;
_activeEntries = new ConcurrentDictionary<string, DeviceCacheEntry>();
}
public async Task<bool> IsDeviceValidAsync(string deviceId)
{
string cacheKey = GetDeviceCacheKey(deviceId);
if (_cache.TryGetValue(cacheKey, out bool isValid))
{
return isValid;
}
var entry = new DeviceCacheEntry(deviceId);
_activeEntries.TryAdd(cacheKey, entry);
try
{
isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
_cache.Set(cacheKey, isValid,
new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(2),
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = OnCacheEntryEvicted,
State = cacheKey
}
}
});
return isValid;
}
catch (Exception ex)
{
_activeEntries.TryRemove(cacheKey, out _);
_logger.LogError(ex, "Error checking device validity for device {DeviceId}", deviceId);
throw;
}
}
public async Task RevokeDeviceAsync(string deviceId)
{
string cacheKey = GetDeviceCacheKey(deviceId);
// Immediately remove from local cache
_cache.Remove(cacheKey);
// Remove from tracking dictionary
_activeEntries.TryRemove(cacheKey, out _);
// Revoke in database
await _deviceRepository.RevokeDeviceAsync(deviceId);
// Publish invalidation event (for distributed scenarios)
await _invalidationPublisher.PublishInvalidationAsync(cacheKey);
_logger.LogInformation("Device {DeviceId} has been revoked", deviceId);
}
public async Task RevokeAllDevicesForAccountAsync(string accountId)
{
var deviceIds = await _deviceRepository.GetDeviceIdsForAccountAsync(accountId);
foreach (var deviceId in deviceIds)
{
await RevokeDeviceAsync(deviceId);
}
_logger.LogInformation("All devices for account {AccountId} have been revoked", accountId);
}
private void OnCacheEntryEvicted(object key, object value, EvictionReason reason, object state)
{
if (state is string cacheKey)
{
_activeEntries.TryRemove(cacheKey, out _);
if (reason == EvictionReason.Expired)
{
_logger.LogDebug("Cache entry for {CacheKey} expired", cacheKey);
}
}
}
private string GetDeviceCacheKey(string deviceId) => $"device_validity_{deviceId}";
}
public class DeviceCacheEntry
{
public string DeviceId { get; }
public DateTime CreatedAt { get; }
public DateTime? LastAccessed { get; private set; }
public DeviceCacheEntry(string deviceId)
{
DeviceId = deviceId;
CreatedAt = DateTime.UtcNow;
LastAccessed = DateTime.UtcNow;
}
public void UpdateAccessTime()
{
LastAccessed = DateTime.UtcNow;
}
}
Alternative Approaches and Trade-offs
1. Shorter Cache Expiration with Stale-While-Revalidate
_cache.Set(cacheKey, isValid,
new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromSeconds(30),
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = (key, value, reason, state) =>
{
if (reason == EvictionReason.Expired)
{
// Background refresh logic
Task.Run(() => RefreshCacheEntry(key.ToString()));
}
},
State = cacheKey
}
}
});
Pros: Reduced window for unauthorized access
Cons: More database hits, potential for inconsistent state
2. Using Cache Tagging
_cache.Set(cacheKey, isValid,
new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(2),
Tags = new[] { $"account_{accountId}", $"device_{deviceId}" }
});
// Invalidate all entries for an account
_cache.RemoveByTag($"account_{accountId}");
Pros: Bulk invalidation capabilities
Cons: Requires MemoryCache compatability pack, slightly higher overhead
3. Hybrid Approach: In-Memory + Distributed Cache
public class HybridDeviceCacheService
{
private readonly IMemoryCache _localCache;
private readonly IDistributedCache _distributedCache;
private readonly IDeviceRepository _deviceRepository;
public async Task<bool> IsDeviceValidAsync(string deviceId)
{
// Try local cache first
if (_localCache.TryGetValue(deviceId, out bool isValid))
{
return isValid;
}
// Try distributed cache
var distributedValue = await _distributedCache.GetStringAsync(deviceId);
if (distributedValue != null)
{
isValid = bool.Parse(distributedValue);
_localCache.Set(deviceId, isValid, TimeSpan.FromMinutes(2));
return isValid;
}
// Check database
isValid = await _deviceRepository.CheckDeviceValidityAsync(deviceId);
await _distributedCache.SetStringAsync(deviceId, isValid.ToString(),
new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(2) });
_localCache.Set(deviceId, isValid, TimeSpan.FromMinutes(2));
return isValid;
}
}
Pros: Good for multi-server deployments, immediate invalidation possible
Cons: More complex implementation, external dependency
4. Using a Dedicated Cache Service
Consider implementing a dedicated cache service that manages device state more comprehensively:
{
private readonly ConcurrentDictionary<string, DeviceState> _deviceStates;
private readonly Timer _cleanupTimer;
public DeviceStateService()
{
_deviceStates = new ConcurrentDictionary<string, DeviceState>();
_cleanupTimer = new Timer(CleanupExpiredDevices, null,
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public bool IsDeviceValid(string deviceId)
{
if (_deviceStates.TryGetValue(deviceId, out var state))
{
if (state.IsValid && !state.IsRevoked && !state.HasExpired)
{
return true;
}
// Remove invalid device
_deviceStates.TryRemove(deviceId, out _);
}
return false;
}
public void RevokeDevice(string deviceId)
{
_deviceStates.AddOrUpdate(deviceId,
key => new DeviceState { IsRevoked = true },
(key, existing) => { existing.IsRevoked = true; return existing; });
}
private void CleanupExpiredDevices(object state)
{
var expiredDevices = _deviceStates
.Where(kv => kv.Value.HasExpired)
.Select(kv => kv.Key)
.ToList();
foreach (var deviceId in expiredDevices)
{
_deviceStates.TryRemove(deviceId, out _);
}
}
public void Dispose()
{
_cleanupTimer?.Dispose();
}
}
public class DeviceState
{
public bool IsValid { get; set; }
public bool IsRevoked { get; set; }
public DateTime LastValidated { get; set; }
public DateTime ExpiresAt { get; set; }
public bool HasExpired => DateTime.UtcNow > ExpiresAt;
}
Pros: Complete control over device state, immediate invalidation
Cons: No built-in caching benefits, needs manual memory management
Conclusion
To effectively handle immediate cache invalidation for device revocation in ASP.NET Core, consider these key points:
-
Choose the right strategy based on your deployment environment - single server vs. multiple servers, performance requirements, and security needs.
-
Implement event-driven invalidation using callbacks, tokens, or event buses to ensure immediate cache removal when devices are revoked.
-
Consider distributed solutions like Redis or SQL Server cache invalidation for multi-server deployments to maintain cache consistency across instances.
-
Balance performance and security - while shorter cache durations reduce security windows, they also increase database load. Find the optimal balance for your application.
-
Monitor and audit cache invalidation events to ensure security requirements are met and to track any potential issues.
The most robust solution typically combines local cache invalidation with distributed notifications, ensuring immediate response while maintaining good performance characteristics. Your current approach is not fundamentally wrong, but adding immediate invalidation mechanisms will eliminate the security vulnerability while still providing most of the performance benefits of caching.