LW Firewall password-reset protection
LW Firewall 1.5.1 added a reset-specific guard. Use this skill before changing
a lost-password form, calling retrieve_password() from a custom transport, or
tuning the reset limits.
Activation and hook boundary
The reset guard initializes only when the MU worker is current, master
enabled is true, and reset_protect_enabled is true. It registers:
| Hook | Role |
|---|---|
lostpassword_form |
render lw_fw_reset_token and lw_fw_confirm_url on core wp-login.php |
lostpassword_post |
apply proof decision and three rate-limit axes before mail |
allow_password_reset |
optionally refuse every privileged account when reset_block_admins is on |
Core retrieve_password() and WooCommerce's account implementation both fire
lostpassword_post with WP_Error and WP_User|false, so their valid-account
requests share the rate limits. A custom form is covered only if its execution
path fires that hook with the expected objects, normally by calling the
canonical reset flow. Direct calls to get_password_reset_key() or custom mail
code bypass it.
Proof versus rate-limit coverage
The proof token and honeypot are enforced only when $GLOBALS['pagenow'] is
wp-login.php, because only lostpassword_form receives the fields. The
WooCommerce form, custom templates, AJAX, and REST requests receive rate-limit
protection but no LW proof check.
Do not copy the private field constants or call private proof_ok() as an
integration API. Use lw-firewall-custom-form-adapter if the custom UI needs a
token/honeypot layer, and record that this is companion-owned behavior rather
than automatic reset-guard coverage.
Three counters
ResetLimiter::record() checks and increments in this order:
| Axis | Defaults | Key | Meaning |
|---|---|---|---|
| IP | 5 per 900 seconds | reset_ip_<ip> |
one requester |
| Global | 30 per hour | reset_all |
all attempts that reach this axis, including some later-refused attempts |
| Target account | 3 per 3600 seconds | reset_user_<user-id> |
one known account |
The first max attempts pass; the next is rejected because the comparison is
count > max. A maximum of zero disables that axis. The target uses user ID, so
login, case variants resolved by WordPress, and email share a bucket.
Evaluation order changed in 1.5.6: ResetLimiter::record() now charges
IP → target account → site-wide, so the hourly email cap is charged last and
only by a request that has cleared everything else. Charging it first let
refused requests drain it: a flood against one account could exhaust the quota
and deny password resets to every other user until the window rolled over.
record_rejected() charges the sender's IP allowance only — bot traffic still
earns its own ban but no longer spends the target's allowance or the site's
email budget.
PasswordResetGuard returns immediately when core/Woo has already put an error
on the WP_Error, so unknown/empty accounts are not counted by this guard.
The site-wide counter is still a request budget rather than a count of emails actually sent, but the 1.5.4 denial-of-service edge is closed: a request refused on the IP or target axis no longer charges it.
Exemptions and refusal behavior
Every reset check is bypassed for:
- WP-CLI;
- a currently authenticated user with
edit_users; - an IP matching
ip_whitelist.
This keeps the Users-screen administrative reset action working. It also means
an authenticated REST request with edit_users is intentionally exempt.
Per-IP and failed-proof verdicts may trigger reset_auto_ban; account and
global limits never ban the last requester. Alerts use the Alerts-tab recipient
policy and are throttled once per verdict per hour. Public messages remain
generic within the proof/rate categories.
Verified v1.5.6 constraints
Fixed since this skill's 1.5.4 grounding — do not re-report these:
- The proof token is now form-bound and per-render.
RegisterTokensigns a per-render nonce and a scope into the payload, so tokens minted in the same second are no longer identical (a shared page cache used to hand one token to every visitor, and single-use rejected all but the first), and a token issued by one form can no longer be presented to another.verify()andcheck()take the scope as their fifth argument — pass the same scope you issued with. - The site-wide counter is charged last (see above), so failed-proof and target-refused traffic no longer drains it.
reset_block_adminscovers every privileged account, not just theadministratorrole slug: multisite super admins and any custom role holdingmanage_optionsare included.- A reset ban is enforced whenever the firewall is on. The worker's
shared-ban lookup is no longer gated on
auto_ban_enabled/login_limit_enabled, soreset_auto_banalone now produces a real block.
Still true in 1.5.6:
- The honeypot checks only for a non-empty value; an omitted field is accepted.
- The proof token is enforced only on
wp-login.php, becauselostpassword_formis the only place it is rendered. Woo, custom and REST reset paths get the rate limits but no LW proof check. reset_block_adminsanswers differently for a privileged account, which allows administrator enumeration. Upstream lists this as known and not fixed: closing it needs the same generic-response handling as lost-password user enumeration.
Regression-test these around any companion behavior.
Configuration and operations
wp lw-firewall reset status --format=json
wp lw-firewall reset on --proof --auto-ban --alert
wp lw-firewall reset on --block-admins
wp lw-firewall reset off
Important options:
reset_protect_enabled
reset_ip_max / reset_ip_window
reset_user_max / reset_user_window
reset_global_max
reset_proof_enabled
reset_min_fill_time / reset_token_max_age / reset_single_use
reset_auto_ban / reset_ban_duration
reset_alert_enabled
reset_block_admins
reset_block_admins can lock the only administrator out of self-service reset.
Require a tested WP-CLI or second-administrator recovery path before enabling
it. reset off preserves the other values.
Companion-form checklist
- Call the canonical
retrieve_password()path; do not reproduce email/key logic. - Confirm that
lostpassword_postruns once and receives the expected objects. - Do not assume Woo/custom/REST forms receive the LW token or honeypot.
- Keep anti-enumeration behavior consistent with the product's reset policy.
- Test valid account, invalid account, IP/global/target thresholds, and recovery after TTL.
- Test core, WooCommerce, custom POST, REST, privileged admin, CLI, and whitelisted IP.
- Test same-second tokens and single-use replay on
wp-login.php, and assert a reset-scope token is refused by the registration form (and vice versa). - Verify that an indexed reset ban is actually enforced and removable.
- Assert that failed-proof and target-refused traffic does NOT charge
reset_all— that ordering is the 1.5.6 fix and is easy to regress. - Test
reset_block_adminsagainst a multisite super admin and a custom role holdingmanage_options, not only theadministratorslug.
Cross-references
- Use
lw-firewall-custom-form-adapterfor a custom proof UI. - Use
lw-firewall-rate-limit-workerfor shared-ban and REST path behavior. - Use
lw-firewall-management-abilitiesfor reset, alert, and ban operations. - Use
wp-security-deepfor the surrounding reset-key, identity, and anti-enumeration security review.
References
- Official project: https://github.com/lwplugins/lw-firewall
- Verified plugin-root-relative sources:
includes/Plugin.phpincludes/Options.phpincludes/Rules/PasswordResetGuard.phpincludes/Rules/ResetLimiter.phpincludes/Rules/ResetPenalty.phpincludes/Rules/RegisterToken.phpincludes/Rules/AutoBanner.phpincludes/CLI/ResetCommand.phpworker/lw-firewall-worker.phpdocs/management.mdCHANGELOG.md