Skip to content
fullstackhero

Concept

Data protection

Valkey-backed Data Protection key persistence so cookies, antiforgery tokens, and IDataProtector payloads survive rolling deploys across instances.

views 0 Last updated

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 IDataProtector consumers — anything you write that calls services.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 path
var 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:

Terminal window
dotnet user-secrets set "Jwt:SigningKey" "$(openssl rand -base64 32)" --project src/Host/FSH.Starter.Api

Production uses environment variables (double underscore for nesting):

Terminal window
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 SetApplicationName values 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.
  • 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.