Skip to content

Data Protection has no persisted key ring, so Identity's token providers break across restarts and replicas #794

Description

@mforce

Important

Amended. The failure boundary below is overstated. An adversarial review of the #788 design
corrected it. ASP.NET Core Data Protection persists keys by default in an environment-dependent
location, and even an ephemeral ring survives across requests within one process. So "a second
replica breaks it" is not automatically true, and src/Cluckwork.Api/Dockerfile already
acknowledges default keys under the application user's home.

Still accurate and worth fixing. There is no explicit, durable, shared configuration. The
real risks are (a) container replacement losing an unmounted key ring, and (b) one instance being
unable to consume a token another instance protected, when no shared ring exists. Generation and
consumption are currently adjacent calls in the same operation, which is why nothing fails today.

Read the body below with that correction applied.


Found while evaluating what ASP.NET Core Identity offers for #788. This is independent of that work and is a latent defect today.

The gap

The repo has no Data Protection configuration anywhere. I checked for each of these and found none: AddDataProtection(), PersistKeysToDbContext, PersistKeysToFileSystem, IDataProtectionProvider/IDataProtector usage, and key-ring settings in any appsettings*.json.

Meanwhile, src/Cluckwork.Api/Hosting/CluckworkIdentityServiceCollectionExtensions.cs does register AddIdentityCore<ApplicationUser>()...AddDefaultTokenProviders(). AddDefaultTokenProviders() registers DataProtectorTokenProvider<TUser>, which uses IDataProtector. With no explicit configuration, ASP.NET Core falls back to a local, unpersisted key ring. That ring is ephemeral in a container, and it is per-process rather than shared.

Why it is latent rather than currently broken

IdentityProvider calls UserManager.GeneratePasswordResetTokenAsync today, so the app does exercise the data-protector path. It does not visibly break because ResetPasswordAsync consumes the generated token in the same request, in the Owner-driven "set a user's password" flow. The token never has to survive a restart, and a different process never has to validate it.

So this is a defect that needs a second condition to appear, not an outage. Two things would trigger it:

  1. A real forgot-password flow. The app emails a token and redeems it minutes or hours later, possibly on a different replica. Tokens would fail validation after any restart or on any other instance.
  2. A second replica. The repo is openly building toward this. Background worker has no single-runner guarantee — double-runs if scaled >1 instance #271 closed its scale-out blockers, Shared-state ports: Redis implementations + in-process fallback #543 added shared state, and Leader lease: dedicated session-pinned endpoint for transaction-pooled deploys #556 tracks the remaining lease work. Instance B cannot verify a data-protector token that instance A minted.

Why it matters beyond password reset

Anything using Identity's in-box token providers inherits this:

  • TOTP enrolment (Add TOTP and WebAuthn/passkey as step-up factors (follow-up to #308) #320), covering the authenticator key and recovery codes. The verified finding there is that TOTP is otherwise nearly free in .NET 10, so a missing key ring is the main real prerequisite for that slice.
  • Email confirmation, if ever added.
  • Any future use of GenerateUserTokenAsync/VerifyUserTokenAsync.

It does not affect the OAuth work in #788 as designed. That design chose reference tokens, which are database lookups rather than protected payloads. OpenIddict does use Data Protection for some internal state, so fixing this first is still the safer order.

Fix

Configure a persisted, shared key ring. Two viable shapes:

Either way, keys should be encrypted at rest. On a host-agnostic deployment, that means a certificate or key supplied through config rather than a provider-specific KMS. This follows the repo's host-agnostic rule.

Not claimed

No user-visible bug exists today, and nothing is currently insecure. With an unpersisted key ring, tokens fail closed (validation fails), not open. The claim is narrower. A mechanism the app already registers cannot work across the restart or replica boundary that the project is deliberately moving toward, and two planned features (#320 and a real forgot-password flow) would expose it immediately.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerenhancementNew feature or requestepic-788OAuth 2.1 authorization server for MCP (#788)priority:tier3Real product weight, real costsize:MA day or two; migration or a multi-state UI

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions