The 2FA that was not, and deleting 555 lines

Note · Engineering~5 min read · NestJS · React · authentication · October 2026

A glass 3D padlock with its shackle open, next to a green checkmark that is not attached to anything

Asset Vault had a two-factor authentication section on its settings page. You scanned a QR code, typed the six digits from your authenticator app, and were told two-factor authentication had been enabled successfully. Then you reloaded the page and it was off, because nothing had been saved, and nothing in the login path would have read it if it had. On 7 September 2026 I removed the feature: 555 lines deleted, 2 added. This is the story of why deleting it was the only honest fix, and of the wrong fix I made first.

The wrong fix: making the buttons work

I met the feature while working the frontend's type-error backlog. The settings page called four API methods for generating, verifying, enabling and disabling 2FA, and none of the four was declared on the frontend client. The setup dialog had been calling functions that did not exist; pressing the button to start could only ever fail. I added the four methods with signatures matching the controller, and the commit message said a feature that could not have worked now could. That sentence was wrong, and the mistake is instructive: I had made the calls succeed, and I had not read what they called.

Reading the server

The next day, while fixing a separate settings bug (21 real fields were being stripped from every save while the form still said "saved"), I read the two-factor service properly. The enable endpoint verified the TOTP token and then returned { enabled: true, message: "Two-factor authentication has been enabled successfully" }, directly beneath its own comment saying the 2FA settings fields had been removed and the state was not stored. The disable endpoint threw "feature has been removed from settings". So the user journey was: scan, enter a valid code, be told it is on, reload, find nothing. I flagged it in that pull request as a decision to make rather than a bug to fix: finish it or remove it.

Why it could not be finished

Finishing looked easy. The enableTwoFactorAuth column existed in the database, and the settings fix I had just made meant the flag would now persist. Two facts made that the worst available option.

  • Nothing read the flag. No guard, no auth check, nothing in the login path. It existed on the entity, the DTO and a migration, and nowhere else.
  • Authentication was not this app's to gate. Login had moved to a managed identity provider (Clerk), which owns the session and signs its own tokens. A boolean in this application's own settings table cannot add a second factor to a login that happens somewhere else.

Persisting the flag would have stored a claim that 2FA was on while login stayed single-factor. That is worse than the bug, because the lie would now survive a reload, and a user who relied on it would have been relying on nothing. Real two-factor authentication for this app means enabling the identity provider's own multi-factor setting, which is configuration, not application code.

What went, and what deliberately stayed

  • Removed: the settings section and the setup dialog (268 lines), the four client methods, the four 2fa/* endpoints with their service and module registration, and seven component-library imports that existed only for that dialog. Net: 555 lines out, 2 in, across 5 files.
  • Kept, the inert column. Dropping it needs a migration, and it harms nothing while nothing reads it.
  • Kept, the strip-list guard. twoFactorSecret is a server-generated credential the update DTO refuses, and a test asserts both that the client keeps stripping it and that it is absent from the settings type, so the two reasons cannot drift apart. Removing 2FA must not quietly open a path for the client to post a secret.

The gate that earned itself the same day

That afternoon the frontend type-check had reached zero errors and CI had started gating on it. Removing the dialog orphaned those seven imports and briefly took the count to seven; under the old non-gating step that would have landed quietly. The cleanup went into the same commit, and the pull request merged with 0 type errors, 98 of 98 frontend tests, 0 backend type errors, 160 of 160 backend tests and a clean production build. A gate that has to refuse something on its first day is a gate worth having.

The takeaway

A feature that reports success and stores nothing is not a half-finished feature. It is a defect that happens to have a nice dialog. The honest options are to make the claim true or to remove the claim, and when the claim is about security, "make it true" has to mean actually gating the thing being protected, not persisting a flag so the dialog agrees with itself. I got this wrong once, for a day, by fixing the buttons. The second time I read the server first.

Read nextA positive control beside every denial