Members are no longer offered fallbacks they cannot use
Shipped 2026-09-21
TUN-870 gave members whose 2FA email bounces a way back in. Two things made that offer hollow for some of them.
A failed 2FA response only explained itself on a bounce
The options, the bounce marker and the flag the panel renders on were all side effects of attempting a send. The send rate limit returns before that, and the validation lockout sits outside it entirely, so both lost all three. The member got a toast and nothing to act on.
It landed hardest on exactly the people the fallback exists for. Their code never
arrives, so they retry, and attempt only resets on a completed login they cannot
make. Four tries in, any security-answer entitlement they already held became
unreachable, because the only route to it is a button on a panel that would not
render.
The answer is now attached once, in handle2FAAuthentication, to the three
failures a member cannot work past on the screen in front of them — a mistyped
code keeps its form rather than being replaced by a panel. Each option is gated
on the server actually accepting it, not on the member merely having it
configured: SMS only when another code could be sent, security questions only
while a bounce is still in window.
”Has security questions” meant “has a row”
The profile editor stored an empty string for a blank field, and replaced a member’s answers by deleting all of them and inserting three — four statements with no transaction. Verification requires every stored row to match, so a blank row or a pair of contradictory duplicates made the fallback unpassable. Six members of 54,190 were in that state.
The duplicates were historical, but the unwrapped delete was not: a failure between the delete and the inserts still wiped a member’s answers, silently removing their 2FA fallback. They would have found out the next time their email bounced.
The write is now a single transaction that upserts what was submitted and removes only what was not, hashing before it opens so bcrypt cannot fail half way, and dropping a blank instead of storing it. Migration 0012 clears the existing blanks, keeps the most recent of each duplicate pair, and adds the unique key the upsert needs.
Measuring it
Nothing recorded how often the fallback was offered, reached or used, so its value
could only be inferred from support tickets. Four events now cover the funnel —
login.recovery_offered, security_answers.screen_shown, .accepted and
.rejected. See Email delivery for how to read
them.