Design: docs/superpowers/specs/2026-08-01-user-disable-and-email-design.md. Deploy A of three — must ship, and the old fleet must be fully drained, before #356 is exposed.
Why this is its own deploy
Adding a per-request check is a fleet-wide change, not a code change. An old replica runs the pre-epoch pipeline and has no CredentialEpochMiddleware to consult, so during a rolling deploy an Owner could disable a user on a new replica while the load balancer keeps routing that user's pre-deploy access token to an old one — which authorizes it. The suspension would be immediate on part of the fleet, which is worse than a documented delay because nothing surfaces it.
Nothing the mutation does can fix that; the gap belongs to the replica that never learned to enforce. So the gate ships first, inert, and the promise ships after.
This deploy makes no new promise. The only epoch bumps it introduces are the existing password-reset paths, which already revoke refresh tokens today — so on an old replica the behaviour is exactly what it is now, and there is no window of false assurance. It can roll at any pace.
Scope
Storage
// ApplicationUser
public int CredentialEpoch { get; set; } = 1; // monotonic; the gate
// RefreshToken
public int IssuedEpoch { get; set; } // the user's epoch when minted
Epoch 0 is permanently retired — the value a row receives when written by something that does not know the column exists, never equal to a live user's epoch. AspNetUsers.CredentialEpoch defaults to 1; refresh_tokens.IssuedEpoch defaults to 0, and every mint site stamps it explicitly (LoginAsync, the rotation, ChangeOwnPasswordAsync's re-issue). That is what makes an old binary's INSERTs inert by construction rather than by timing, since migrate runs before the new serving process (#263).
Cutover. The migration revokes every existing refresh token (UPDATE refresh_tokens SET "RevokedAt" = now() WHERE "RevokedAt" IS NULL) — one forced re-login for everyone. Revocation alone is not sufficient (the epoch separation is what stops a stolen legacy token burning the victim's post-re-login family); it is kept as defence-in-depth, deliberately in the direction where a regressed comparison degrades to lockout rather than to access.
Enforcement. JwtTokenService stamps the claim. New CredentialEpochMiddleware:
UseAuthentication → TenantResolutionMiddleware → CredentialEpoch → MustChangePassword → UseAuthorization → Idempotency
After authentication (before it, every caller looks anonymous and it is inert), before UseAuthorization (applies regardless of AuthPolicies tier), before idempotency (a blocked write burns no key). Look up by both UserId and AccountId — ApplicationUser has no tenant query filter — untracked projection, skip re-execution under IExceptionHandlerFeature, leave auth/logout reachable.
An absent or unparsable claim is a mismatch, not an exemption. Every pre-deploy access token carries no claim and stays valid ~15 min. The codebase's own convention pulls the wrong way here — must_change_password is omitted when false, so "claim absent ⇒ doesn't apply" is an idiom already in hand, and it is correct there. A convention that is right for a feature flag is a vulnerability for a revocation check. Missing/malformed parses to 0, which flows through the ordinary comparison.
RefreshAsync refuses a disabled user and any IssuedEpoch mismatch — ahead of the #176 grace/replay branch, not merely ahead of the rotation. A revoked token reaches RevokeAllActiveForUserAsync before FindByIdAsync loads the user, so a check placed near the rotation never runs on that path; the consequence is a repeatable denial of service (hold a superseded token, wait for the victim to log back in, burn their new family). A mismatch fails inert — generic error, no family revocation. Same-epoch reuse still burns the family, which is #176's actual purpose.
Bumps: all three password-reset paths (self-service, SetUserPasswordAsync, break-glass).
SPA: carry a 401's error title through auth teardown. client.ts turns any authenticated 401 into refresh-and-retry then calls a parameterless onUnauthenticated, and Login.tsx maps every 401 to "invalid credentials" — so a distinct title is necessary but not sufficient. Needed here rather than in #356 so the mutations land on plumbing that already exists.
Tests
- A live access token stops working on the next request after a bump.
- Cutover, against a pre-migration seeded database: a pre-existing refresh token is refused; after the user logs in again the stolen legacy token still cannot burn their new family (revocation alone passes the first and fails this); a pre-migration access token is refused; a default-
0 row standing in for an old binary mid-rollout is refused, and refused inert.
- A malformed epoch claim is refused — pinned separately from absent, since they are two code paths and only one is obvious.
- Barrier-controlled: a refresh in flight across a bump leaves no usable child token, asserted by presenting the child, not by checking its access token; a superseded token must not burn the current family; genuine same-epoch reuse still must.
- Middleware ordering asserted explicitly — the guarantee is positional and a silent reorder fails open.
Docs
GLOSSARY.md gains credential epoch. Deployment requirement stated in the repo (portable): deploy B must not be exposed until no process from before this deploy is still serving. The drain mechanism itself belongs to the deployment/ops repo.
Blocks
#356, then #357.
Design:
docs/superpowers/specs/2026-08-01-user-disable-and-email-design.md. Deploy A of three — must ship, and the old fleet must be fully drained, before #356 is exposed.Why this is its own deploy
Adding a per-request check is a fleet-wide change, not a code change. An old replica runs the pre-epoch pipeline and has no
CredentialEpochMiddlewareto consult, so during a rolling deploy an Owner could disable a user on a new replica while the load balancer keeps routing that user's pre-deploy access token to an old one — which authorizes it. The suspension would be immediate on part of the fleet, which is worse than a documented delay because nothing surfaces it.Nothing the mutation does can fix that; the gap belongs to the replica that never learned to enforce. So the gate ships first, inert, and the promise ships after.
This deploy makes no new promise. The only epoch bumps it introduces are the existing password-reset paths, which already revoke refresh tokens today — so on an old replica the behaviour is exactly what it is now, and there is no window of false assurance. It can roll at any pace.
Scope
Storage
Epoch
0is permanently retired — the value a row receives when written by something that does not know the column exists, never equal to a live user's epoch.AspNetUsers.CredentialEpochdefaults to1;refresh_tokens.IssuedEpochdefaults to0, and every mint site stamps it explicitly (LoginAsync, the rotation,ChangeOwnPasswordAsync's re-issue). That is what makes an old binary'sINSERTs inert by construction rather than by timing, sincemigrateruns before the new serving process (#263).Cutover. The migration revokes every existing refresh token (
UPDATE refresh_tokens SET "RevokedAt" = now() WHERE "RevokedAt" IS NULL) — one forced re-login for everyone. Revocation alone is not sufficient (the epoch separation is what stops a stolen legacy token burning the victim's post-re-login family); it is kept as defence-in-depth, deliberately in the direction where a regressed comparison degrades to lockout rather than to access.Enforcement.
JwtTokenServicestamps the claim. NewCredentialEpochMiddleware:After authentication (before it, every caller looks anonymous and it is inert), before
UseAuthorization(applies regardless ofAuthPoliciestier), before idempotency (a blocked write burns no key). Look up by bothUserIdandAccountId—ApplicationUserhas no tenant query filter — untracked projection, skip re-execution underIExceptionHandlerFeature, leaveauth/logoutreachable.An absent or unparsable claim is a mismatch, not an exemption. Every pre-deploy access token carries no claim and stays valid ~15 min. The codebase's own convention pulls the wrong way here —
must_change_passwordis omitted when false, so "claim absent ⇒ doesn't apply" is an idiom already in hand, and it is correct there. A convention that is right for a feature flag is a vulnerability for a revocation check. Missing/malformed parses to0, which flows through the ordinary comparison.RefreshAsyncrefuses a disabled user and anyIssuedEpochmismatch — ahead of the #176 grace/replay branch, not merely ahead of the rotation. A revoked token reachesRevokeAllActiveForUserAsyncbeforeFindByIdAsyncloads the user, so a check placed near the rotation never runs on that path; the consequence is a repeatable denial of service (hold a superseded token, wait for the victim to log back in, burn their new family). A mismatch fails inert — generic error, no family revocation. Same-epoch reuse still burns the family, which is #176's actual purpose.Bumps: all three password-reset paths (self-service,
SetUserPasswordAsync, break-glass).SPA: carry a 401's error title through auth teardown.
client.tsturns any authenticated 401 into refresh-and-retry then calls a parameterlessonUnauthenticated, andLogin.tsxmaps every 401 to "invalid credentials" — so a distinct title is necessary but not sufficient. Needed here rather than in #356 so the mutations land on plumbing that already exists.Tests
0row standing in for an old binary mid-rollout is refused, and refused inert.Docs
GLOSSARY.mdgains credential epoch. Deployment requirement stated in the repo (portable): deploy B must not be exposed until no process from before this deploy is still serving. The drain mechanism itself belongs to the deployment/ops repo.Blocks
#356, then #357.