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

Two flags for one boolean, drifted apart

Back to blog
FILE 0x2B·TWO FLAGS FOR ONE BOOLEAN, DRIFTED APART
Back to blog
FILE 0x2B·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:

  • totp_secret
  • totp_enabled
  • totp_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.