Back to blog
FILE 0x2B·TWO FLAGS FOR ONE BOOLEAN, DRIFTED APART

Two flags for one boolean, drifted apart

April 28, 2026 · debugging, auth, schema

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:

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.