ASP.NET Core’s Data Protection API encrypts cookies, antiforgery tokens, and any payload you ask it to with IDataProtector. Default behaviour stores the keys in the local filesystem — fine for a single-process host, broken for multi-instance deployments where each instance maintains its own key ring and decryption fails across nodes. The kit fixes this by persisting the key ring to Valkey (a Redis-compatible, BSD-licensed Redis fork) through the shared multiplexer.
What gets protected
IDataProtector is used implicitly by:
- ASP.NET Identity cookie auth — if you swap JWT for cookies in your fork, those cookies are Data-Protection-encrypted.
- Antiforgery tokens —
[ValidateAntiForgeryToken]on form posts uses Data Protection. - Token providers in
UserManager— email confirmation tokens, password reset tokens, 2FA recovery codes. These are encrypted+signed with Data Protection. - Custom
IDataProtectorconsumers — anything you write that callsservices.GetRequiredService<IDataProtectionProvider>().CreateProtector("MyPurpose").Protect(...).
In the kit’s default JWT-bearer mode, the most visible payloads are the email confirmation tokens + password reset tokens sent in emails. If those don’t decrypt on the next instance the user lands on, the link is dead.
How it’s wired
// AddHeroCaching (simplified)var redis = ConnectionMultiplexer.Connect(opts.Redis);services.AddSingleton<IConnectionMultiplexer>(redis);
services.AddDataProtection() .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys") .SetApplicationName("fullstackhero");Two important details:
- Same multiplexer as the cache + (optionally) SignalR backplane. One connection pool covers all three.
SetApplicationName— Data Protection isolates keys by application name. If you run two fullstackhero hosts against the same Valkey (staging + dev sharing a Valkey instance), use distinct application names so they don’t fight over the key ring.
What happens without it
Without Valkey-backed persistence (e.g. you forgot to set CachingOptions:Redis), each instance writes its key ring to its local filesystem inside the container. Symptoms:
- First request after deploy on a different pod: user gets logged out / asked to re-confirm email / token-expired errors.
- Email confirmation links from one deploy stop working after the next deploy if the new pod doesn’t share the keys.
- Antiforgery validation fails intermittently across pods.
The fix is simple — set CachingOptions:Redis. There’s no “production-grade-without-Valkey” alternative; if Valkey isn’t available, use Postgres for key persistence via PersistKeysToDbContext(...) (the kit doesn’t ship this by default but it’s a 10-line change).
Key rotation
Data Protection rotates keys automatically every 90 days by default. The active key is used for new protections; expired keys are kept for decrypting older payloads up to a retention period. The key ring grows over time — Valkey storage is negligible (a few KB).
Explicit rotation isn’t necessary in normal operation; the framework handles it. For incident response (a key may have leaked), revoke the entire ring and force a deploy:
// One-off recovery pathvar keyManager = services.GetRequiredService<IKeyManager>();foreach (var key in keyManager.GetAllKeys()) keyManager.RevokeKey(key.KeyId, "Suspected leak; cycling.");Everything currently encrypted with the revoked keys becomes unreadable — users have to re-confirm emails, request new password resets, etc.
Secrets in configuration
Not the same thing as Data Protection, but tightly related. The kit’s config-borne secrets — Jwt:SigningKey, MailOptions:Smtp:Password, MailOptions:SendGrid:ApiKey, Storage:S3:SecretKey, HangfireOptions:Password, CachingOptions:Redis (if it contains a password) — must come from a secrets manager, not from appsettings.json checked into git.
Local development uses dotnet user-secrets:
dotnet user-secrets set "Jwt:SigningKey" "$(openssl rand -base64 32)" --project src/Host/FSH.Starter.ApiProduction uses environment variables (double underscore for nesting):
JWT__SIGNINGKEY=...MAILOPTIONS__SENDGRID__APIKEY=...STORAGE__S3__SECRETKEY=...…or your cloud secrets manager (Azure Key Vault provider, AWS Systems Manager Parameter Store, etc.) integrated through Microsoft.Extensions.Configuration.
Gotchas
- The shared multiplexer crash story. When Valkey goes down, Data Protection key reads fail for the duration of the outage. Cookie auth keeps working because keys are cached in-memory after the first read; new encryptions can’t happen. Plan your Valkey HA accordingly (Sentinel, Cluster, or a managed Redis-compatible service with multi-AZ).
- Different application names mean different key rings. Two hosts that share Valkey but use different
SetApplicationNamevalues can’t decrypt each other’s payloads. That’s usually what you want for multi-env isolation; it’s the wrong thing if you accidentally set different names on instances of the same logical app. - Don’t change application name once you’ve started shipping. The keys encrypted under the old name become unreadable. If you need to rename, plan a deprecation window where you run both names side-by-side.
Related
- Caching — the shared multiplexer used here.
- Authentication — JWT tokens (which the kit uses by default) are signed, not Data-Protection-encrypted — but email tokens are.
- Production checklist — Data Protection wiring is item #9.