diff --git a/.agents/rules/security.md b/.agents/rules/security.md
index b3fb38404b..1ee2f2d7f8 100644
--- a/.agents/rules/security.md
+++ b/.agents/rules/security.md
@@ -24,3 +24,45 @@ Opt-in per endpoint with **`.WithIdempotency()`**. Reads the `Idempotency-Key` h
`QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based.
`IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled.
+
+## Data Protection keys (`Caching/`, `Persistence/DataProtection/`)
+
+Data Protection encrypts auth cookies, password-reset and confirmation tokens, and antiforgery
+tokens. Two settings decide whether that survives.
+
+**`DataProtection:ApplicationName`** is what keys are isolated by, so it MUST be unique per
+application. It is read from configuration, never hard-coded: the framework also ships as compiled
+`FSH.Framework.*` packages, where the template's token substitution cannot reach a literal, so a
+constant would make every project on those packages share one key ring — and two of them against
+the same store could decrypt each other's cookies and tokens. `appsettings.json` is scaffolded
+source, so the value there is renamed per project in both distribution modes. Unconfigured, it
+falls back to the entry assembly name, which errs towards isolation.
+
+**`DataProtection:Store`** is `Redis` (default) or `Database`.
+
+| | Redis | Database |
+|---|---|---|
+| Wired in | `AddHeroCaching` | `AddHeroPlatform` |
+| Needs | a configured Redis | `DataProtectionKeysDbContext` + its migrations |
+| Choose when | Redis is durable and shared by every host | hosts do not reliably share Redis, or Redis is a cache with eviction |
+
+Pick `Database` when the DbMigrator is run standalone — outside the AppHost wiring that injects a
+Redis connection string — since anything its seed encrypts otherwise becomes undecryptable by the
+API (`CryptographicException: key {guid} not found in the key ring`). Redis eviction has the same
+effect on a live system: losing a key takes every session and pending reset token with it.
+
+The DbMigrator additionally creates the key table **before** it starts its host
+(`DataProtectionSchema.EnsureAsync`). Data Protection resolves its key ring eagerly during
+`StartAsync`, long before the migrator's own Step 0/1/2 flow, so the `IDbInitializer` alone is too
+late there: the first run against an empty database logs a query failure with a stack trace and —
+worse — does not fail, because a key created while the table is missing cannot be persisted, and
+anything encrypted in that window is undecryptable afterwards. The initializer still covers the API
+and the test harness, which migrate before they serve.
+
+`DataProtectionKeysDbContext` is a plain `DbContext`, not `BaseDbContext` — keys are global
+framework infrastructure, not tenant data. It registers a `DataProtectionKeysDbInitializer` like
+every other framework context, which is what makes the table appear in the API, the DbMigrator and
+the integration-test harness alike. **Wiring the context without its `IDbInitializer` is the
+failure mode to avoid**: the table then only exists wherever someone migrated it by hand, and every
+flow that protects a payload fails with `Invalid object name 'DataProtectionKeys'` everywhere else.
+
diff --git a/src/BuildingBlocks/Caching/Extensions.cs b/src/BuildingBlocks/Caching/Extensions.cs
index bdfd138781..1cab90cf38 100644
--- a/src/BuildingBlocks/Caching/Extensions.cs
+++ b/src/BuildingBlocks/Caching/Extensions.cs
@@ -1,3 +1,4 @@
+using FSH.Framework.Core.DataProtection;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Configuration;
@@ -60,9 +61,21 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services
// Persist Data Protection keys (auth cookies, reset/confirmation tokens, antiforgery) to
// Redis so multi-instance hosts share a key ring and tokens survive rolling restarts.
- services.AddDataProtection()
- .PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys")
- .SetApplicationName("FSH.Starter");
+ // Skipped when the database store is selected: AddHeroPlatform wires the key ring to
+ // DataProtectionKeysDbContext instead. Configuring both would leave the last
+ // PersistKeysTo call silently deciding where keys actually land.
+ //
+ // The application name comes from configuration (see #1372): a literal here would ship
+ // inside the compiled FSH.Framework.Caching package, where the template cannot rename
+ // it, making every project on that package share one key ring.
+ if (!DataProtectionStores.UsesDatabase(configuration[DataProtectionStores.ConfigurationKey]))
+ {
+ services.AddDataProtection()
+ .PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys")
+ .SetApplicationName(
+ DataProtectionApplicationName.Resolve(
+ configuration[DataProtectionApplicationName.ConfigurationKey]));
+ }
}
// HybridCache auto-composes with whatever IDistributedCache is registered above.
diff --git a/src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs b/src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs
new file mode 100644
index 0000000000..e170a34fba
--- /dev/null
+++ b/src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs
@@ -0,0 +1,24 @@
+namespace FSH.Framework.Core.DataProtection;
+
+///
+/// Where ASP.NET Core Data Protection keys are persisted, selected by
+/// DataProtection:Store in configuration.
+///
+/// Lives in Core because both Caching (which owns the Redis store) and Web (which owns the
+/// database store) have to agree on the value, and Caching cannot reference Persistence.
+///
+public static class DataProtectionStores
+{
+ /// Configuration key selecting the store.
+ public const string ConfigurationKey = "DataProtection:Store";
+
+ /// Redis, when configured. The default, and what the framework has always used.
+ public const string Redis = "REDIS";
+
+ /// The application database, via the framework's Data Protection keys context.
+ public const string Database = "DATABASE";
+
+ /// True when configuration selects the database store.
+ public static bool UsesDatabase(string? configuredValue) =>
+ string.Equals(configuredValue?.Trim(), Database, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbContext.cs b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbContext.cs
new file mode 100644
index 0000000000..13a5bf1ec0
--- /dev/null
+++ b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbContext.cs
@@ -0,0 +1,35 @@
+using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace FSH.Framework.Persistence.DataProtection;
+
+///
+/// Persists ASP.NET Core Data Protection keys to the application database.
+///
+///
+///
+/// The alternative store is Redis, which stays the default. Database-backed keys matter when the
+/// hosts that protect and unprotect data do not reliably share a Redis instance: the DbMigrator is
+/// normally run standalone, outside the AppHost wiring that injects a Redis connection string, so
+/// anything its seed encrypts can become permanently undecryptable by the API — a
+/// CryptographicException: key {guid} not found in the key ring that no amount of pinning
+/// the application name will fix, because the two hosts simply have different key rings. Redis
+/// eviction has the same effect on a live system: keys stored in a cache can be evicted, taking
+/// every session and pending reset token with them.
+///
+///
+/// Deliberately a plain rather than BaseDbContext: Data Protection
+/// keys are global framework infrastructure, not tenant data, so they are neither tenant-filtered
+/// nor duplicated into a tenant's dedicated database.
+///
+///
+/// Provider-neutral. It is wired through AddHeroDbContext like every other context, so it
+/// follows DatabaseOptions:Provider, and each provider's migrations project carries its own
+/// DataProtection/ folder for it.
+///
+///
+public sealed class DataProtectionKeysDbContext(DbContextOptions options)
+ : DbContext(options), IDataProtectionKeyContext
+{
+ public DbSet DataProtectionKeys => Set();
+}
diff --git a/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbInitializer.cs b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbInitializer.cs
new file mode 100644
index 0000000000..306d502906
--- /dev/null
+++ b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbInitializer.cs
@@ -0,0 +1,46 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace FSH.Framework.Persistence.DataProtection;
+
+///
+/// Migrates the Data Protection key schema.
+///
+///
+/// Registered as an so the table is created by whatever brings the
+/// schema up — the API host, the DbMigrator, and the integration-test harness alike. Wiring the
+/// context without an initializer is the failure mode this exists to prevent: the table then only
+/// appears wherever someone remembered to migrate it by hand, and every flow that protects a
+/// payload (registration, password reset, two-factor) fails with a
+/// CryptographicException everywhere else.
+///
+/// The context is not tenant-aware, so this targets the root connection on every pass; after the
+/// first it is a no-op, which is why running inside the per-tenant loop is harmless.
+///
+public sealed partial class DataProtectionKeysDbInitializer : IDbInitializer
+{
+ private readonly DataProtectionKeysDbContext _context;
+ private readonly ILogger _logger;
+
+ public DataProtectionKeysDbInitializer(
+ DataProtectionKeysDbContext context,
+ ILogger logger)
+ {
+ _context = context;
+ _logger = logger;
+ }
+
+ public async Task MigrateAsync(CancellationToken cancellationToken)
+ {
+ if ((await _context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any())
+ {
+ await _context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
+ LogMigrated();
+ }
+ }
+
+ public Task SeedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "applied database migrations for the Data Protection key schema")]
+ private partial void LogMigrated();
+}
diff --git a/src/BuildingBlocks/Persistence/DataProtection/DataProtectionSchema.cs b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionSchema.cs
new file mode 100644
index 0000000000..7307288997
--- /dev/null
+++ b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionSchema.cs
@@ -0,0 +1,47 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace FSH.Framework.Persistence.DataProtection;
+
+///
+/// Creates the Data Protection key table before anything can read the key ring.
+///
+///
+///
+/// covers hosts that migrate before they serve — the
+/// API and the integration-test harness. It is not enough for the DbMigrator, which builds and
+/// STARTS a full host before running its own migration flow: Data Protection resolves its key ring
+/// eagerly during that start, queries the table, and logs a failure with a stack trace on every
+/// first run against an empty database. Nothing crashes, which is worse than crashing — a key
+/// created while the table is missing cannot be persisted, so anything encrypted in that window is
+/// undecryptable afterwards.
+///
+///
+/// Deliberately standalone: it builds its own context rather than resolving one from the host,
+/// because the whole point is to run before the host exists.
+///
+///
+public static class DataProtectionSchema
+{
+ /// Applies pending migrations for the Data Protection key schema.
+ public static async Task EnsureAsync(
+ string dbProvider,
+ string connectionString,
+ string migrationsAssembly,
+ bool isDevelopment,
+ CancellationToken cancellationToken = default)
+ {
+ var options = new DbContextOptionsBuilder();
+
+ // ConfigureHeroDatabase rather than a bare UseNpgsql/UseSqlServer: it is the one call that
+ // picks the provider, points at that provider's migrations assembly and applies the
+ // provider-specific options, so this follows DatabaseOptions:Provider like every context.
+ options.ConfigureHeroDatabase(dbProvider, connectionString, migrationsAssembly, isDevelopment);
+
+ await using var context = new DataProtectionKeysDbContext(options.Options);
+
+ if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any())
+ {
+ await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/BuildingBlocks/Persistence/Persistence.csproj b/src/BuildingBlocks/Persistence/Persistence.csproj
index a01f48bfc8..66a397cc78 100644
--- a/src/BuildingBlocks/Persistence/Persistence.csproj
+++ b/src/BuildingBlocks/Persistence/Persistence.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs
index 50c6568fda..a43664ba2b 100644
--- a/src/BuildingBlocks/Web/Extensions.cs
+++ b/src/BuildingBlocks/Web/Extensions.cs
@@ -1,16 +1,17 @@
using FSH.Framework.Caching;
+using FSH.Framework.Core.DataProtection;
using FSH.Framework.Jobs;
using FSH.Framework.Mailing;
using FSH.Framework.Persistence;
+using FSH.Framework.Persistence.DataProtection;
using FSH.Framework.Quota;
using FSH.Framework.Shared.Constants;
using FSH.Framework.Web.Auth;
using FSH.Framework.Web.Cors;
using FSH.Framework.Web.Exceptions;
using FSH.Framework.Web.FeatureFlags;
-using FSH.Framework.Web.Idempotency;
-using FSH.Framework.Web.Sse;
using FSH.Framework.Web.Health;
+using FSH.Framework.Web.Idempotency;
using FSH.Framework.Web.Mediator.Behaviors;
using FSH.Framework.Web.Modules;
using FSH.Framework.Web.Observability.Logging.Serilog;
@@ -20,15 +21,19 @@
using FSH.Framework.Web.RateLimiting;
using FSH.Framework.Web.Realtime;
using FSH.Framework.Web.Security;
+using FSH.Framework.Web.Sse;
using FSH.Framework.Web.Versioning;
+using Mediator;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
+using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
-using Mediator;
namespace FSH.Framework.Web;
@@ -104,6 +109,27 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild
}
}
+ // Data Protection keys in the database rather than Redis. Wired here because this is the
+ // only place that sees both Caching (which owns the Redis store) and Persistence (which
+ // owns the context); Caching skips its own wiring when this store is selected.
+ //
+ // Worth choosing when the hosts that protect and unprotect data do not reliably share a
+ // Redis instance - the DbMigrator is normally run standalone, outside the AppHost wiring
+ // that injects a Redis connection string - or when Redis is a cache with eviction, where
+ // losing a key evicts every session and pending reset token with it.
+ if (DataProtectionStores.UsesDatabase(builder.Configuration[DataProtectionStores.ConfigurationKey]))
+ {
+ builder.Services.AddHeroDbContext();
+ builder.Services.TryAddEnumerable(
+ ServiceDescriptor.Scoped());
+
+ builder.Services.AddDataProtection()
+ .PersistKeysToDbContext()
+ .SetApplicationName(
+ DataProtectionApplicationName.Resolve(
+ builder.Configuration[DataProtectionApplicationName.ConfigurationKey]));
+ }
+
if (options.EnableFeatureFlags)
{
builder.Services.AddHeroFeatureFlags(builder.Configuration);
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 0d38b28190..1b7696005c 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -79,6 +79,7 @@
+
diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json
index 293fdfebb6..eb80e9e9d7 100644
--- a/src/Host/FSH.Starter.Api/appsettings.json
+++ b/src/Host/FSH.Starter.Api/appsettings.json
@@ -64,6 +64,13 @@
"OriginOptions": {
"OriginUrl": "https://localhost:7030"
},
+ "DataProtection": {
+ // "Redis" (default) or "Database". Choose Database when the hosts that protect and unprotect
+ // data do not reliably share a Redis instance - the DbMigrator is normally run standalone,
+ // outside the AppHost wiring that injects a Redis connection string - or when Redis is a cache
+ // with eviction, where losing a key takes every session and pending reset token with it.
+ "Store": "Redis"
+ },
"CachingOptions": {
"Redis": ""
},
diff --git a/src/Host/FSH.Starter.DbMigrator/Program.cs b/src/Host/FSH.Starter.DbMigrator/Program.cs
index dbdd12f345..2207d229bb 100644
--- a/src/Host/FSH.Starter.DbMigrator/Program.cs
+++ b/src/Host/FSH.Starter.DbMigrator/Program.cs
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Reflection;
+using FSH.Framework.Core.DataProtection;
using FSH.Framework.Eventing;
+using FSH.Framework.Persistence.DataProtection;
using FSH.Framework.Shared.Multitenancy;
using FSH.Framework.Web;
using FSH.Framework.Web.Modules;
@@ -161,6 +163,36 @@ await Console.Error.WriteLineAsync(
// the DI graph is satisfied; the verb dispatch below decides whether to call it.
builder.Services.AddScoped();
+// The Data Protection key table has to exist BEFORE the host starts, when keys live in the
+// database. Data Protection resolves its key ring eagerly during StartAsync, well before this
+// file's own Step 0/1/2 flow runs, so leaving it to the IDbInitializer means the first run against
+// an empty database logs a query failure with a stack trace - and, worse, does not fail: a key
+// created while the table is missing cannot be persisted, so anything encrypted in that window is
+// undecryptable afterwards. The initializer still covers the API and the test harness, which
+// migrate before they serve.
+//
+// This waits for the database itself, because Step 0's wait is also after StartAsync.
+if (DataProtectionStores.UsesDatabase(builder.Configuration[DataProtectionStores.ConfigurationKey]))
+{
+ using var bootstrapLoggerFactory = LoggerFactory.Create(b => b.AddConsole());
+ var bootstrapLogger = bootstrapLoggerFactory.CreateLogger("DataProtectionSchema");
+
+ var bootstrapProvider = builder.Configuration["DatabaseOptions:Provider"] ?? DbProviders.PostgreSQL;
+ var bootstrapConnectionString = builder.Configuration["DatabaseOptions:ConnectionString"]
+ ?? throw new InvalidOperationException("DatabaseOptions:ConnectionString is not configured.");
+
+ await MigratorLockFactory.Create(bootstrapProvider)
+ .WaitForDatabaseAsync(bootstrapConnectionString, bootstrapLogger, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ await DataProtectionSchema.EnsureAsync(
+ bootstrapProvider,
+ bootstrapConnectionString,
+ builder.Configuration["DatabaseOptions:MigrationsAssembly"]
+ ?? throw new InvalidOperationException("DatabaseOptions:MigrationsAssembly is not configured."),
+ builder.Environment.IsDevelopment()).ConfigureAwait(false);
+}
+
using var host = builder.Build();
var logger = host.Services.GetRequiredService>();
diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.Designer.cs
new file mode 100644
index 0000000000..44424ef66a
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.Designer.cs
@@ -0,0 +1,48 @@
+//
+using FSH.Framework.Persistence.DataProtection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.MSSQL.DataProtection
+{
+ [DbContext(typeof(DataProtectionKeysDbContext))]
+ [Migration("20260909230643_InitialDataProtection")]
+ partial class InitialDataProtection
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.cs b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.cs
new file mode 100644
index 0000000000..cf2e6d8ea8
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.cs
@@ -0,0 +1,35 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.MSSQL.DataProtection
+{
+ ///
+ public partial class InitialDataProtection : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DataProtectionKeys",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ FriendlyName = table.Column(type: "nvarchar(max)", nullable: true),
+ Xml = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DataProtectionKeys");
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs
new file mode 100644
index 0000000000..d9dae2c160
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs
@@ -0,0 +1,45 @@
+//
+using FSH.Framework.Persistence.DataProtection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.MSSQL.DataProtection
+{
+ [DbContext(typeof(DataProtectionKeysDbContext))]
+ partial class DataProtectionKeysDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.Designer.cs
new file mode 100644
index 0000000000..c8b3a1ef36
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.Designer.cs
@@ -0,0 +1,48 @@
+//
+using FSH.Framework.Persistence.DataProtection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.PostgreSQL.DataProtection
+{
+ [DbContext(typeof(DataProtectionKeysDbContext))]
+ [Migration("20260909230633_InitialDataProtection")]
+ partial class InitialDataProtection
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.cs
new file mode 100644
index 0000000000..d24e27c226
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.PostgreSQL.DataProtection
+{
+ ///
+ public partial class InitialDataProtection : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DataProtectionKeys",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ FriendlyName = table.Column(type: "text", nullable: true),
+ Xml = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DataProtectionKeys");
+ }
+ }
+}
diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs
new file mode 100644
index 0000000000..4e72f3134e
--- /dev/null
+++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs
@@ -0,0 +1,45 @@
+//
+using FSH.Framework.Persistence.DataProtection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FSH.Starter.Migrations.PostgreSQL.DataProtection
+{
+ [DbContext(typeof(DataProtectionKeysDbContext))]
+ partial class DataProtectionKeysDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs
index ab8cfe3c65..166f47c977 100644
--- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs
+++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs
@@ -117,6 +117,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
["DatabaseOptions:ConnectionString"] = _postgres.GetConnectionString(),
["DatabaseOptions:MigrationsAssembly"] = "FSH.Starter.Migrations.PostgreSQL",
["CachingOptions:Redis"] = "",
+ // The suite runs without Redis, so the framework's default key ring would be
+ // ephemeral and in-process - neither store exercised. Pointing Data Protection at
+ // the database instead means every test that registers a user, resets a password
+ // or enrols in two-factor also proves the key table is actually migrated and
+ // writable.
+ ["DataProtection:Store"] = "Database",
["JwtOptions:Issuer"] = TestConstants.JwtIssuer,
["JwtOptions:Audience"] = TestConstants.JwtAudience,
["JwtOptions:SigningKey"] = TestConstants.JwtSigningKey,