Data Protection Keys

Understand what ASP.NET Core Data Protection encrypts, and persist and protect its key ring so many instances and restarts keep working.

Syntaxbuilder.Services.AddDataProtection() .SetApplicationName("orders-api") .PersistKeysToFileSystem(new DirectoryInfo("/var/dp-keys")) .ProtectKeysWithCertificate(certificate);

Data Protection is ASP.NET Core's built-in authenticated encryption for short-lived payloads that the app hands to a client and later gets back. The framework uses it for authentication cookies, antiforgery tokens, the OpenID Connect correlation and nonce cookies, session cookie ids, and Identity's email confirmation and password reset tokens. You can use it yourself through IDataProtectionProvider.CreateProtector(purpose). JWT bearer tokens are not involved: the identity provider signs those.

Keys, purposes and the application name

The key ring is a set of XML key files. One key is the default for Protect; every payload records the id of the key that produced it, so Unprotect can use older keys. Keys last 90 days by default and are generated automatically before the current one expires. Expired keys still decrypt old payloads; only revoked keys stop working. Keys are never deleted for you, and deleting them invalidates everything they protected.

A protector is isolated by its purpose string and by the application name, the discriminator. Payloads made for Orders.ResumeCheckout.v1 cannot be opened by a protector for Orders.PasswordReset.v1. The default application name is the content root path, which is /app in nearly every container image. Call SetApplicationName("orders-api") explicitly: with the same value on every replica, and a different value per application sharing a key store, or one app's auth cookies become valid in another.

Where keys live by default

EnvironmentDefaultProblem
WindowsUser profile, encrypted with DPAPIMachine-local
Linux, macOS, containers$HOME/.aspnet/DataProtection-Keys, unencrypted (warning: No XML encryptor configured)Inside the container's writable layer: gone on restart, different per replica
No user profileMemory onlyNew keys on every start

The production setup

Every instance of an app must read and write the same key ring. Options: PersistKeysToFileSystem on a shared volume, a database through your own IXmlRepository or the EF Core package, Redis, Azure Blob Storage, or AWS Systems Manager through AWS's own package. Then encrypt keys at rest with ProtectKeysWithCertificate or a cloud key vault; whoever can read an unencrypted key file can forge auth cookies for any user. When the certificate rotates, keep the old one in UnprotectKeysWithAnyCertificate until keys encrypted with it have expired.

Data Protection is not for data at rest that must outlive the key ring, such as stored card tokens or documents. A lost or revoked key ring makes those unreadable forever. Use envelope encryption with a key management service for that.

Production failure modes

  • Users logged out after every deploy, and random sign-outs after scaling to two pods: keys are per container.
  • Antiforgery 400s and OIDC Correlation failed errors behind a load balancer without sticky sessions.
  • Password reset links in emails failing after a restart.
  • Logs showing The key {id} was not found in the key ring, the signature of all of the above.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Keep customers signed in across a rolling deploy of three orders API pods by sharing the key ring on a ReadWriteMany volume.
  • Issue a resume-checkout link with a time-limited protector that expires after 24 hours and fails cleanly when tampered with.
  • Stop random antiforgery 400s after scaling the customer portal from one replica to four.

More examples

Production registration: shared volume and certificate

<code>ProtectKeysWithCertificate</code> encrypts each new key file with the certificate's public key, so the shared volume holds nothing usable on its own. The private key must reach every replica, which is why it comes from a secret. Rotation is the step teams miss: new keys are encrypted with the new certificate, but existing keys stay encrypted with the old one until they age out, so the old certificate must remain available for decryption.

Example · csharp
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.DataProtection;

var builder = WebApplication.CreateBuilder(args);

// Mounted from Kubernetes: a ReadWriteMany volume for keys and a secret for the certificate.
var keyPath = builder.Configuration["DataProtection:KeyPath"] ?? "/var/dp-keys";
var certificate = X509CertificateLoader.LoadPkcs12FromFile(
    builder.Configuration["DataProtection:CertificatePath"] ?? "/etc/dp-cert/current.pfx",
    builder.Configuration["DataProtection:CertificatePassword"]);

var dataProtection = builder.Services.AddDataProtection()
    .SetApplicationName("orders-api")
    .PersistKeysToFileSystem(new DirectoryInfo(keyPath))
    .ProtectKeysWithCertificate(certificate);

// During certificate rotation, keys encrypted with the previous certificate must stay readable.
var previousPath = builder.Configuration["DataProtection:PreviousCertificatePath"];
if (!string.IsNullOrEmpty(previousPath))
{
    var previous = X509CertificateLoader.LoadPkcs12FromFile(
        previousPath, builder.Configuration["DataProtection:PreviousCertificatePassword"]);
    dataProtection.UnprotectKeysWithAnyCertificate(certificate, previous);
}

builder.Services.AddAuthentication().AddCookie();
var app = builder.Build();
app.MapGet("/", () => "ok");
app.Run();

The key ring volume in Kubernetes

All three replicas mount the same <code>ReadWriteMany</code> volume, so a cookie issued by one pod is readable by the others and survives rollouts. A <code>ReadWriteOnce</code> volume would only attach to pods on one node, which is a common surprise. If the cluster has no shared file storage, store the keys in the database or a cache through an <code>IXmlRepository</code> instead.

Example · yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: orders-api-dp-keys
spec:
  accessModes: ["ReadWriteMany"]
  storageClassName: efs-sc
  resources:
    requests:
      storage: 10Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
spec:
  replicas: 3
  selector:
    matchLabels: { app: orders-api }
  template:
    metadata:
      labels: { app: orders-api }
    spec:
      containers:
        - name: api
          image: registry.example.com/orders-api:2026.10.3
          env:
            - name: DataProtection__KeyPath
              value: /var/dp-keys
            - name: DataProtection__CertificatePath
              value: /etc/dp-cert/current.pfx
            - name: DataProtection__CertificatePassword
              valueFrom:
                secretKeyRef: { name: orders-api-dp-cert, key: password }
          volumeMounts:
            - { name: dp-keys, mountPath: /var/dp-keys }
            - { name: dp-cert, mountPath: /etc/dp-cert, readOnly: true }
      volumes:
        - name: dp-keys
          persistentVolumeClaim: { claimName: orders-api-dp-keys }
        - name: dp-cert
          secret:
            secretName: orders-api-dp-cert
            items:
              - { key: pfx, path: current.pfx }

Keys in PostgreSQL with a custom IXmlRepository

<code>IXmlRepository</code> is the whole storage contract: return every element, store a new one. Plugging it in through <code>KeyManagementOptions.XmlRepository</code> keeps the keys in the database the service already depends on, with its backups and access control. Combine it with certificate or key vault encryption, because the rows are otherwise plain XML containing the key material.

Example · csharp
using System.Xml.Linq;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.Repositories;
using Microsoft.Extensions.Options;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(builder.Configuration.GetConnectionString("Orders")!));

builder.Services.AddDataProtection().SetApplicationName("orders-api");
builder.Services.AddSingleton<IConfigureOptions<KeyManagementOptions>>(services =>
    new ConfigureOptions<KeyManagementOptions>(options =>
        options.XmlRepository = new PostgresXmlRepository(services.GetRequiredService<NpgsqlDataSource>())));

var app = builder.Build();
app.MapGet("/", () => "ok");
app.Run();

// Table: CREATE TABLE data_protection_keys (friendly_name text PRIMARY KEY, xml text NOT NULL);
public sealed class PostgresXmlRepository(NpgsqlDataSource dataSource) : IXmlRepository
{
    // The interface is synchronous; it is called rarely (start-up and key ring refresh).
    public IReadOnlyCollection<XElement> GetAllElements()
    {
        using var command = dataSource.CreateCommand("SELECT xml FROM data_protection_keys");
        using var reader = command.ExecuteReader();
        var elements = new List<XElement>();
        while (reader.Read())
        {
            elements.Add(XElement.Parse(reader.GetString(0)));
        }
        return elements;
    }

    public void StoreElement(XElement element, string friendlyName)
    {
        using var command = dataSource.CreateCommand(
            "INSERT INTO data_protection_keys (friendly_name, xml) VALUES ($1, $2) ON CONFLICT (friendly_name) DO NOTHING");
        command.Parameters.AddWithValue(friendlyName);
        command.Parameters.AddWithValue(element.ToString(SaveOptions.DisableFormatting));
        command.ExecuteNonQuery();
    }
}

Discussion

  • Be the first to comment on this lesson.