NeuroAgent

Complete Guide to IMemoryCache Invalidation for Device Security

Eliminate the 2-minute security vulnerability in device management with immediate IMemoryCache invalidation techniques. Comprehensive guide with implementation examples.

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?

NeuroAgent

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

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:

csharp
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:

  1. The 2-minute expiration elapses, or
  2. 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.

csharp
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:

csharp
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

csharp
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:

csharp
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

csharp
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

csharp
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.SizeLimit for large objects

Concurrency Considerations

  • Use thread-safe collections for tracking cache entries
  • Implement proper locking mechanisms when needed
  • Consider using ConcurrentDictionary for 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:

csharp
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

csharp
_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

csharp
_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

csharp
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:

cpublic class DeviceStateService
{
    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:

  1. Choose the right strategy based on your deployment environment - single server vs. multiple servers, performance requirements, and security needs.

  2. Implement event-driven invalidation using callbacks, tokens, or event buses to ensure immediate cache removal when devices are revoked.

  3. Consider distributed solutions like Redis or SQL Server cache invalidation for multi-server deployments to maintain cache consistency across instances.

  4. Balance performance and security - while shorter cache durations reduce security windows, they also increase database load. Find the optimal balance for your application.

  5. 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.

Sources

  1. Microsoft Docs - Memory cache in ASP.NET Core
  2. Microsoft Docs - Distributed caching in ASP.NET Core
  3. Stack Overflow - How to remove specific item from MemoryCache
  4. Microsoft Docs - Background tasks with hosted services in ASP.NET Core
  5. Microsoft Docs - PostEvictionCallbackRegistration