Secrets and Data Protection
User secrets in development, environment variables and a secret store in production, and why replicas must share the key ring.
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo("/keys")).SetApplicationName("payments");A secret is a string that grants access: a database password, an API key, a signing key. Secrets never belong in source control, because a repository is copied, forked and kept forever.
In development
dotnet user-secrets init adds a UserSecretsId to the project file, and dotnet user-secrets set "Payments:ApiKey" "abc" stores the value in your user profile, outside the project tree. WebApplication.CreateBuilder loads those values automatically, but only when the environment is Development. They are not encrypted; they are merely somewhere Git cannot see.
In production
Supply secrets as environment variables or from a secret store such as Azure Key Vault. A configuration key nests with a colon, Payments:ApiKey. Some shells reject colons in variable names, so every platform also accepts a double underscore: Payments__ApiKey means the same key. Environment variables are plain text to anyone who can read the process.
Data Protection
ASP.NET Core encrypts things it hands to clients: authentication cookies, antiforgery tokens, password-reset links. It does that with a key ring, a small set of keys it creates and rotates for itself every 90 days. Keys must outlive the process and be shared by every replica. By default they land in the user profile, or nowhere at all if there is no profile, which is exactly the case inside a container.
So point the key ring at shared storage with PersistKeysToFileSystem, Redis or a database, and call SetApplicationName with the same value in every replica.
Example
using Microsoft.AspNetCore.DataProtection;
var builder = WebApplication.CreateBuilder(args);
// In Development this comes from user secrets. In production the same key is
// read from an environment variable named Payments__ApiKey.
string apiKey = builder.Configuration["Payments:ApiKey"]
?? throw new InvalidOperationException("Payments:ApiKey is not configured.");
// /keys is a mounted volume, not container disk, so the key ring survives a
// restart and every replica reads the same keys.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/keys"))
.SetApplicationName("payments");
var app = builder.Build();
// Proves the secret loaded without printing it.
app.MapGet("/key-length", () => apiKey.Length);
app.MapGet("/round-trip", (IDataProtectionProvider provider) =>
{
// The purpose string separates uses of the same key ring.
IDataProtector protector = provider.CreateProtector("Payments.Receipt");
string sealedValue = protector.Protect("order-1001");
return protector.Unprotect(sealedValue);
});
app.Run();When to use it
- Run an app locally against a real payment API without the test key reaching Git.
- Stop users being signed out at random after scaling an app to three replicas.
- Load a production database password from a Kubernetes Secret with no code change.
More examples
Setting and reading secrets in development
Secret Manager exists for one reason: so no developer ever needs to paste a key into appsettings.json to get the app running. It is not a vault, and a production secret should never be set here. Reach for it on day one of a project, before the first key arrives.
# Once per project: adds <UserSecretsId> to the .csproj.
dotnet user-secrets init
# The colon means Payments is an object with an ApiKey property.
dotnet user-secrets set "Payments:ApiKey" "sk_test_4eC39H"
dotnet user-secrets set "ConnectionStrings:Orders" "Host=localhost;Database=orders"
# What this project has. Values are stored in plain text in your user profile:
# ~/.microsoft/usersecrets/<id>/secrets.json (Linux, macOS)
# %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json (Windows)
dotnet user-secrets list
# Remove one, or all of them.
dotnet user-secrets remove "Payments:ApiKey"
dotnet user-secrets clearThe same configuration in Kubernetes
The app code does not change between a laptop and a cluster; only the source of the values does. The volume matters as much as the secret: three replicas with three key rings produce intermittent sign-outs that look like a session bug and are not. A Kubernetes Secret is base64-encoded, not encrypted, so enable encryption at rest or use an external store.
apiVersion: v1
kind: Secret
metadata:
name: payments
stringData:
# Double underscore where the configuration key has a colon.
Payments__ApiKey: sk_live_abc123
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: registry.example.com/payments:1.4.0
envFrom:
- secretRef:
name: payments
volumeMounts:
# All three replicas mount the one key ring.
- name: dp-keys
mountPath: /keys
volumes:
- name: dp-keys
persistentVolumeClaim:
claimName: payments-dataprotectionA shared key ring without a shared volume
Shared file storage is awkward on many platforms, and Redis is usually already there for caching. One warning carried from the documentation: Redis does not persist data across a restart by default, and a key ring that vanishes signs everybody out. Turn on Redis persistence before relying on this.
using Microsoft.AspNetCore.DataProtection;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
IConnectionMultiplexer redis = await ConnectionMultiplexer.ConnectAsync(
builder.Configuration.GetConnectionString("Redis")!);
builder.Services.AddDataProtection()
// Requires Microsoft.AspNetCore.DataProtection.StackExchangeRedis.
.PersistKeysToStackExchangeRedis(redis, "payments-dataprotection-keys")
// Must match across every replica and every deployment of this app.
.SetApplicationName("payments")
// Default is 90 days. Shorten it only if you have a reason.
.SetDefaultKeyLifetime(TimeSpan.FromDays(90));
var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok());
app.Run();
Discussion