On 1 September 2026 I reopened the claim handler on a membership site I have maintained for a long time. I was re-reading a class of damage I had once declared closed, to find out whether it really was.
Here is the background. Every auth form on this site carries a timestamp field named mbr_ts baked straight into the HTML, so the age of a form is counted from when the page was rendered, not from when the person started typing. If that page ever gets served from a copy that has been sitting around, the form a visitor receives can already be past its time window before a single character is entered. The window itself had been widened earlier.
What I never checked was what happens after the guard answers no.
One boolean for two very different stories
Every auth form here POSTs to admin-post.php with an action, wp_nonce_field, a set of guard fields from mbr_form_guard_fields($action), and a Turnstile field from mbr_turnstile_field(). The part that judges the submission checks more than one thing, but the outcome is squeezed into a single yes or no through mbr_form_is_bot().
The name is honest about what it returns. That is exactly the problem. An expired form and a genuine bot submission leave that function in identical shape, and the claim handler had only one branch for both. That branch redirects to claim=check-inbox, which in the same file is the success screen.
Roughly like this, simplified so the point is visible rather than quoted from the file as it stands:
// one branch for every rejection reason
if (mbr_form_is_bot($action)) {
wp_safe_redirect(add_query_arg('claim', 'check-inbox', $back));
exit;
}For a bot that branch is clever. It walks away believing it worked, and nothing is stored. For a real person whose form happened to be old, the same branch turns into a very quiet little disaster: they fill in the claim form, press submit, read a screen telling them to check their inbox, and then wait for an email that will never be sent.
The loss is silent in the literal sense. There is nothing for anyone to see except the person doing the waiting.
The module that had already split them
The least comfortable part of this finding is that the split I needed already existed in the same codebase, just not in this spot. The leads module already used mbr_form_guard_reason(), a function that returns the rejection reason rather than a bare yes or no.
So the fix was not new logic. It was calling a function that already existed and adding one branch. In simplified form:
// the reasons that used to collapse into a single boolean
$reason = mbr_form_guard_reason($action);
if ($reason === 'stale') {
// a real person, page left open too long
} elseif ($reason === 'bot') {
// nothing needs to be explained here
}In my notes it genuinely was a five-minute fix. The expensive part was never the fix. It was the distance between the moment that branch started being wrong and the moment anyone thought to look at it.
What the numbers said once the reasons were split
I did not want to close this on a code reading alone, so I submitted several forms of different ages and shapes and recorded the status coming out of the guard.
age 30 seconds -> accepted
age 13 hours -> stale
age 1 second -> bot
honeypot filled in -> botA 30-second-old form was accepted. A 13-hour-old form came back stale. A form submitted one second after the page opened came back bot, and a filled honeypot came back bot as well. Before the reasons were split, stale and bot left the guard in the same shape, so both fell into the same branch. Stale now lands on claim=stale, with a message that the session expired and a request to refresh the page and submit again.
One practical note if you test something like this on a live site. A single IP is allowed only five claim submissions inside a window of 1800 seconds, so the test order has to be paced. Otherwise the thing answering the next submission is no longer the guard you are testing, it is the rate limiter, and your reading goes blurry without you noticing.
The same defect elsewhere, and the part I deliberately left alone
Once the shape of the defect was clear, I went looking for the same shape elsewhere. The lost password handler, mbr_handle_lostpw(), had an exactly congruent defect: a form older than 12 hours landed on sent=1 with a message saying an email had been sent, and no email was sent.
This is where I nearly made a second mistake, a subtler one than the first. After finding a rejection path that lies, the reflex is to make every rejection path honest while you are in there. On the lost password form, that reflex does damage.
The success answer on a filled honeypot and on a rate-limited submission has to stay a success. Not out of laziness, but because differentiating the answer in those two cases tells anyone guessing which addresses are registered and which are not. The only wrong one there was stale, and answering stale honestly leaks nothing, because the message is about the page being old, not about the address that was typed. So stale was the only one I moved, to auth=expired.
I ran the test in production against the real handler, and the only thing I faked was Turnstile's network answer. A fresh form ended on sent=1, a stale form on auth=expired, a bot submission on sent=1, and no mail was sent in any of the three. That last part is what I watched most closely, because it means a test on a live site left no trace in anyone's inbox.
That left the other callers to sweep. mbr_form_is_bot() is still called in four more places, and I checked all four one at a time. Register, login and reset all redirect to a visible error, so nobody is lost there.
Distinguish the reason, do not flatten the answer
A function that returns a boolean forces its caller to write one branch for every rejection reason. As long as the reasons are equivalent, that never hurts. The moment one of them turns out to be a real person, that single branch stops being a simplification and becomes a loss recorded nowhere.
Four things came home with me from this case, and the third is the easiest to miss:
- If a check has several failure reasons that are not equivalent, return the reason, not the conclusion. The caller knows the context; the guard function does not.
- A rejection path landing on a success screen is not merely a wrong message. It is a silent loss, and the cost is carried by someone out there waiting for something that is not coming.
- Fixing uniformly is dangerous in its own right. On the lost password form, a filled honeypot and a rate-limited submission must keep answering success, and that is a deliberate security decision, not an oversight.
- If one module in the same repo already separates rejection reasons, that is not a coincidence. It is work that is already correct in one place and has not been levelled across to the others.