ADR 0006 — Rehash-on-login persistence#
Status: Accepted (Phase 1, 2026-08-26) Depends on: 0001 — Authentication model, 0004 — Database migration strategy
Context#
ADR 0001 (amended) requires rehash-on-login: after a successful legacy password verification, the plaintext is rehashed with BCrypt and the stored value must be updated so the next login uses the modern algorithm and eventually removes the machine-key dependency at runtime.
The obvious place to write the new hash is the same column the legacy verifier reads — aspnet_Membership.Password — flipping PasswordFormat to a Cube 2.0 sentinel value. That directly violates ADR 0004:
Migrations never claim ownership of a legacy table they didn’t create. New migrations are always forward-only deltas. The legacy site still writes to
aspnet_Membership.LastLoginDate,FailedPasswordAttemptCount,IsLockedOut, etc.; introducing a competing writer for the same row invites lost-update races and breaks the legacy site if it ever encounters a$2*-prefixed value it doesn’t understand.
We considered three options:
| Option | Summary | Rejected because |
|---|---|---|
A. Write BCrypt back into aspnet_Membership.Password | Reuse existing column, flip PasswordFormat = 99 (or similar sentinel). | Violates ADR 0004 (writing to a legacy-owned table). Legacy site would fail to verify passwords for rehashed users and would potentially corrupt them on next password change. Two writers, no coordinating lock. |
| B. Cube-2.0-owned auth store | New table MnxUserAuth owned by Cube 2.0. Verify checks new store first, falls back to legacy. | Chosen. |
| C. In-memory cache only | Cache the verified plaintext hash in the Web app process for the session. | Pointless — session-scoped, doesn’t outlive a restart, never removes the machine-key dependency. |
Decision#
Introduce a Cube-2.0-owned table MnxUserAuth, shipped as the first EF Core migration on this codebase.
Schema#
CREATE TABLE MnxUserAuth (
UserId uniqueidentifier NOT NULL PRIMARY KEY,
PasswordHash nvarchar(255) NOT NULL, -- BCrypt hash ($2b$…)
HashUpdatedAt datetime2 NOT NULL,
HashAlgorithm nvarchar(20) NOT NULL DEFAULT ('bcrypt'),
CONSTRAINT FK_MnxUserAuth_aspnet_Users
FOREIGN KEY (UserId) REFERENCES aspnet_Users (UserId)
);UserId— 1:1 withaspnet_Users; PK doubles as the FK.PasswordHash— BCrypt output, includes cost factor + salt in the string.HashUpdatedAt— for audit + eventual “everyone’s migrated, flip the flag” query.HashAlgorithm— future-proofs a swap from BCrypt to argon2/etc.
No index beyond the PK. MnxUserAuth is read/written 1:1 with the login flow.
Provisioning#
An idempotent SQL delta ships as deploy/sql/phase-1-add-mnxuserauth.sql. Ops applies it once against staging + prod before the app is deployed with this change. The script:
- Guards on
sys.tablesso a rerun is a no-op. - Creates the FK to
aspnet_Users(UserId)with no cascade — deliberate; a legacy user-delete should not silently sweep Cube 2.0 auth state. - Includes the cutover-tracking query as a comment for later reuse.
The proper EF Core migration lands once ops points us at a real staging DB to scaffold a baseline snapshot from (per ADR 0004). Until then, the SQL delta is the source of truth and dotnet ef database update is not run.
Verification flow#
UserAuthService.AuthenticateAsync, after resolving AspnetUsers:
- Look up
MnxUserAuthforUserId. If present andPasswordHashverifies with BCrypt → success, skip legacy path. - Otherwise, load
aspnet_Membershipand run the legacy verifier (PasswordFormatdispatch per ADR 0001). - On successful legacy verification, upsert
MnxUserAuthwithBCrypt(plaintext)andHashUpdatedAt = UtcNow. Fire-and-forget is not enough — we complete the write before issuing the token so the next login uses the new store. - Return the token.
AuthResult.RehashedPasswordreflects whether the upsert happened.
Failure modes#
- Duplicate insert race (two concurrent logins for the same user, both legacy-verified): a single-row upsert using a SQL
MERGE(or the EF CoreUpdate-then-Insertfallback) leaves either row acceptable — both write the same BCrypt output pattern. MnxUserAuthwrite fails after legacy verify succeeds: log a warning, still return the token. The next login just re-verifies against legacy and re-tries the rehash.- Legacy row deleted between verify and upsert: the FK insert fails; we already returned the token but the user is effectively gone. Extremely unlikely; a warning log is enough.
Cutover#
Cube 2.0 stops reading the machine key at runtime when every active user has a MnxUserAuth row. Operational check:
SELECT COUNT(*) AS legacy_only
FROM aspnet_Users u
LEFT JOIN MnxUserAuth a ON a.UserId = u.UserId
WHERE a.UserId IS NULL
AND EXISTS (SELECT 1 FROM aspnet_Membership m WHERE m.UserId = u.UserId AND m.LastLoginDate > DATEADD(day, -90, GETUTCDATE()));When legacy_only = 0 for a chosen window, flip FeatureFlags:UseLegacyPasswordHash = false. Users who haven’t logged in inside the window remain on the legacy path until they do; if they never do, they’re out of scope for cutover.
The legacy aspnet_Membership.Password value is never modified by Cube 2.0.
Consequences#
- The migration folder gains its first EF Core migration (
AddMnxUserAuth). Establishes the pattern for future deltas per ADR 0004. - Login flow now issues at most one extra
SELECT(fast — PK lookup) on the hot path. WhenMnxUserAuthexists, the legacy verifier — and therefore the machine key — is not touched. MnxUserAuthis Cube-2.0-owned. The legacy site does not read it, does not write it, and will not break if we drop or re-shape it.- Password changes issued through Cube 2.0’s future
/api/v1/auth/change-passwordwrite only toMnxUserAuth. Password changes issued through the legacy site still updateaspnet_Membership.Password; users who go that route slip back to the legacy path until their next Cube 2.0 login, which upsertsMnxUserAuthfresh. This is acceptable during coexistence. - Machine-key dependency at runtime disappears the moment the cutover query is clean and the flag flips. The BCrypt hashes in
MnxUserAuthremain valid without the machine key.
What we deliberately don’t do#
- We don’t back-fill
MnxUserAuthin a batch. Rehash happens lazily on login. Batch backfill would require knowing every plaintext — impossible by design. - We don’t sync deletes. If the legacy site deletes a user from
aspnet_Users, theMnxUserAuthrow is orphaned by the FK’sON DELETEdefault (no cascade — legacy delete just fails). That’s intentional: Cube 2.0 shouldn’t be the reason legacy user delete blocks. If this ever bites, addON DELETE CASCADEin a follow-up migration. - We don’t index
HashUpdatedAt. The cutover query above scans, which is fine at expected user counts (<100k). Add later if needed. - We don’t record the previous algorithm.
HashAlgorithmdescribes the current stored value only. Rotation history stays out of scope for Phase 1.