Two flags for one boolean, drifted apart
A user of a side-project reported that the "Set up 2FA in mobile app" link in their settings did nothing — and there was no way to move 2FA to a new device when they got a new phone. Tracking it down landed on the most boring kind of bug: two boolean fields that mean the same thing, written and read inconsistently.
What was happening
The user record had three TOTP-related fields:
totp_secrettotp_enabledtotp_verified
The setup flow set totp_verified = true on successful first
verification. The rest of the app read totp_enabled to decide
whether 2FA was active. totp_enabled was never set by the
setup flow. So:
POST /auth/2fa/verify → totp_verified = true (totp_enabled untouched)
GET /auth/me → reports totp_enabled = false
Settings UI → shows "Set up 2FA" link (which does nothing)
The user had 2FA active in the sense that login required it, but the UI reported it as not configured.
What I found
This is a classic "shouldn't there be one field for this" bug.
Somebody had added totp_verified to mark the post-QR-scan
verification step, planning to flip totp_enabled after some
grace period, then never did. The two flags drifted apart in
prod across many users.
The fix
Three small changes, picked to be backward-compatible with existing rows:
// 1. On verify: set BOTH flags
function handle_verify_totp($userId) {
// ... existing TOTP check ...
$db->updateItem('users', ['id' => $userId], [
'totp_verified' => true,
'totp_enabled' => true,
]);
}
// 2. On read: union of the two (legacy users see correct state)
function handle_me($userId) {
$u = $db->getItem('users', ['id' => $userId]);
$u['totp_enabled'] = (bool)($u['totp_enabled'] ?? false)
|| (bool)($u['totp_verified'] ?? false);
return $u;
}
// 3. On disable: clear all three
function handle_disable_2fa($userId) {
$db->updateItem('users', ['id' => $userId], [
'totp_secret' => null,
'totp_enabled' => false,
'totp_verified' => false,
]);
}
Plus a one-row backfill for the user who reported it, and a new
TwoFactorSection web component with three flows: Set up, Move
to new device (verify current → disable → fetch new QR → verify
new), Done.
What I'd do differently
If you find yourself adding a second boolean to represent a state that an existing boolean almost represents, stop and collapse them. The pressure to add a new field instead of migrating data feels temporary; the resulting drift is forever.
The "move 2FA to a new device" flow is also a feature nobody thinks about until somebody buys a new phone. Worth designing into any 2FA implementation on day one rather than the day somebody complains.