A few weeks after a client membership site went live, I checked the database out of curiosity. The members table: empty. Not quiet, literally zero. Not a single user had ever registered since day one. The register form was there, rendered fine, submitted fine, and never showed a single error.
And it was not just registration. Member login, lost password, the claim flow: every front-end form used by logged-out users failed the exact same way. Submit, page moves, then nothing happens. No error, no notice, nothing in the logs. The worst kind of failure: a silent one.
Dead end: "works on my machine"
First reflex, test it myself. I opened the site, filled in the register form, submitted. Worked. Lost password: reset email arrived. The claim flow: smooth. Everything green in my hands while the data said zero members had ever made it in.
At this point it is tempting to conclude "users are doing it wrong". Luckily the numbers were too extreme for that. One or two users failing is normal. Zero out of everyone across weeks is not user error, it is a system rejecting everybody except me.
I re-read the form handler line by line: valid. The form markup and action URL: correct, POSTing to wp-admin/admin-post.php exactly the way WordPress wants it. The admin_post_nopriv_* hooks were registered. Everything looked right, and it was right. The problem lived somewhere else.
Dissecting the request instead of the code
Since the code looked clean, I stopped reading code and started dissecting the request. I sent a POST to admin-post.php manually with redirect: 'manual' so the redirect would not be followed automatically and I could inspect the raw response:
const res = await fetch('https://client-site.test/wp-admin/admin-post.php', {
method: 'POST',
body: params,
redirect: 'manual',
});
console.log(res.status, res.headers.get('location'));The result: a 302, with a Location header pointing at /account/. Not a success page, not an error page. The request was being bounced to the login area BEFORE the form handler ever got a chance to run. The access log confirmed the same pattern: POST comes in, immediate 302, handler never touched.
That redirect to /account/ looked very familiar. I wrote it myself.
Root cause: admin-post.php also fires admin_init
Some time earlier I had hardened wp-admin: any non-staff user touching the admin area gets redirected out to /account/. Implemented on the admin_init hook:
add_action('admin_init', function () {
if (is_staff()) {
return;
}
wp_safe_redirect(home_url('/account/'));
exit;
});The intent was simple: regular users have no business in the wp-admin dashboard. What I missed: admin_init does not belong to dashboard pages alone. wp-admin/admin-post.php, the official WordPress entry point for processing public form submissions, ALSO calls do_action('admin_init') early in its execution. So does admin-ajax.php.
Which means: every time a logged-out user submitted register, login, lost password, or claim, the request went through admin-post.php, admin_init fired, is_staff() returned false, and wp_safe_redirect plus exit killed the request before a single line of the handler ran.
And why did it "work on my machine"? Because I was always logged in as an admin while testing. is_staff() returned true, the lockdown stepped aside, the handler ran normally. That staff bypass is what made the bug invisible to the developer and fatal to everyone else.
The fix: exclude the two public entry scripts
The fix is tiny: the lockdown needs to know that admin-post.php and admin-ajax.php are not "admin pages". They are public entry points that happen to live inside the wp-admin folder.
add_action('admin_init', function () {
if (in_array($GLOBALS['pagenow'] ?? '', ['admin-post.php', 'admin-ajax.php'], true)) {
return;
}
if (is_staff()) {
return;
}
wp_safe_redirect(home_url('/account/'));
exit;
});$GLOBALS['pagenow'] holds the name of the entry file currently executing. Those two get excluded, everything else stays locked down. After deploying, I repeated the manual POST test: no more 302 to /account/, the handler ran, and the first real member finally registered.
The takeaway
admin_initdoes not mean "admin dashboard only".admin-post.phpandadmin-ajax.phpfire it too, and both are public form paths.- If you test public features while logged in as an admin, you are not testing what users experience. Always run public flows from a logged-out or incognito session.
- Silently failing forms are fastest to diagnose at the HTTP layer, not the code layer:
redirect: 'manual', read the status andLocationheader, match it against the access log. - Extreme numbers are a clue. One or two failures might be user error; zero successes total means the system itself is doing the blocking.
