ENGINEERING

A deploy silently broke everyone’s MFA - and what it taught us about encrypting the Data Protection key ring

ASP.NET Core’s Data Protection stack is one of those things that “just works” until the day it spectacularly doesn’t. Here’s the three-layer hole we found in ours, and the live migration it took to close it properly.

Layer 1: the key ring evaporated on every deploy

We host on an ephemeral container platform (Render). Out of the box, Data Protection writes its key ring to the local filesystem - which gets wiped on every deploy and container rebuild.

The symptom was eerie: a user with the correct password and the correct TOTP code would fail to log in. Nothing was wrong with their credentials. The problem was that we use IDataProtector to encrypt MFA secrets and stored credential passwords, and after a deploy the key that had encrypted them was gone. Same data, same code, but the cipher key had been regenerated out from under us, so every decrypt failed.

Fix: persist the key ring somewhere durable - for us, Postgres.

builder.Services.AddDataProtection()
    .SetApplicationName("RFF.Api")
    .PersistKeysToDbContext<AppDbContext>();

Now the ring survives rebuilds. Crisis over - except we’d just created the next one.

Layer 2: the key ring was sitting in the database in plaintext

PersistKeysToDbContext stores the keys as plaintext XML. So the master keys that protect every credential password and MFA secret in the system were now sitting in a table, in the clear. Anyone who could read the database - a backup leak, a misconfigured replica, an over-broad support query - could lift those keys and decrypt everything.

The fix is to wrap the ring with an X.509 certificate so the keys are stored encrypted:

var cert = new X509Certificate2(
    Convert.FromBase64String(pfxBase64), pfxPassword,
    X509KeyStorageFlags.EphemeralKeySet); // private key in memory only - read-only container FS

dpBuilder.ProtectKeysWithCertificate(cert);

Two details worth stealing:

else if (builder.Environment.IsProduction())
    throw new InvalidOperationException(
        "Refusing to start in Production with an unencrypted Data Protection key ring.");

Flip the cert on, redeploy, done - right? Not even close.

Layer 3: turning on encryption protects nothing you already have

This is the part that surprised me, and it’s the reason a lot of “we added ProtectKeysWithCertificate” setups are quietly still exposed.

ProtectKeysWithCertificate only encrypts keys created after you switch it on. Your existing key - the plaintext one - stays in the ring and stays the active encryptor until it expires (~90 days). So immediately after the change:

  1. Every value already in the DB is still under the plaintext key. Unchanged.
  2. Worse, newly protected data also lands under the old plaintext key. Data Protection won’t promote a brand-new key to default encryptor until it clears a ~2-day propagation window (designed for multi-server rollouts), so during that window the immature new key is ignored and the plaintext key keeps winning.

Flipping the config flag felt like a fix but changed nothing on disk. To actually cut over, we had to force a key rotation and re-encrypt the existing data - carefully, on a live database, without a window where a value is readable by nobody.

The cutover: rotate, re-encrypt, verify before you destroy anything

We wrote a one-shot startup routine. It runs before the app serves traffic, gated behind a config flag and an idempotency marker so it can never run twice. The ordering matters:

// 1. New key, cert-wrapped (because the cert is now configured), active *now*
//    (back-date activation so it isn't stuck "immature").
keyManager.CreateNewKey(activationDate: now.AddMinutes(-5),
                        expirationDate: now.AddDays(90));

// 2. Revoke every old key. THIS is what defeats the ~2-day propagation window -
//    revoked keys leave the default-encryptor candidate set, so the new key wins now.
foreach (var keyId in oldKeyIds)
    keyManager.RevokeKey(keyId, "Rotated to certificate-wrapped key.");

// 3. Read each value with DangerousUnprotect (ignoreRevocationErrors: true - we just
//    revoked the key that encrypted it) and re-Protect under the new key.
var plain      = DangerousUnprotectToString(protector, value);
var reprotected = protector.Protect(plain);

// 4. VERIFY each value round-trips through the NORMAL, revocation-honoring Unprotect,
//    entirely in memory, BEFORE SaveChanges. If anything fails to land under a live
//    key, we throw and persist nothing.
if (protector.Unprotect(reprotected) != plain)
    throw new InvalidOperationException("Re-encryption verification failed.");

// 5. Only after the verified SaveChanges: delete the old plaintext key rows.
// 6. Stamp an idempotency marker so this never runs again.

The two things that make this safe:

One more subtlety we had to respect: the key-store writes in steps 1-2 don’t touch the protector’s in-memory key-ring cache - the cache is built lazily on the first protect/unprotect call. So all key mutation has to happen before the re-encrypt loop, while nothing has materialized the ring yet. Running at startup, pre-traffic, guarantees that.

Takeaways

That last rule - verify before you destroy - shows up on the other end of the wire too. The agent seals its own credential at rest with Windows DPAPI and round-trips the decrypt before it blanks the cleartext; the one-line enrollment story covers that side.


We’re building RFF - hosted fleet management for Windows. We store admin credentials and MFA secrets, so this stuff isn’t optional; here’s how we secure it. Free for your first 100 endpoints.