LW Firewall custom-form adapter
LW Firewall 1.5.6 has dedicated guards for core registration and password reset. It does not expose a generic “attach this firewall to any form” service, field descriptor, render/validate interface, or form registry.
Use this skill only when a companion plugin deliberately builds an adapter from the lower-level public classes. Do not present the adapter as a built-in generic LW Firewall feature.
Choose the correct contract
| Form | Current LW integration |
|---|---|
| Core WordPress registration | automatic RegisterGuard when its activation conditions pass |
| Custom user registration | use lw-firewall-registration-guard |
| Core lost password | automatic proof plus limits |
| Woo/custom lost password using canonical hook | rate limits; proof only on core wp-login.php |
| Arbitrary contact/lead/application form | no automatic honeypot or token |
/wp-json/ form endpoint |
optional shared REST rate bucket only |
Do not invoke RegisterGuard::validate() for an unrelated form: it reads fixed
registration fields from $_POST, increments registration-specific rejection
counters, and can create a misleading register_spam ban.
Reusable primitives and their limits
RegisterToken::issue() and verify() are public static methods, and
RateLimiter accepts a custom storage key. They are reusable only with a
version-pinned adapter and explicit fallback policy; their class names and
settings remain registration-oriented.
The token format changed in 1.5.6 — this is a breaking change for adapters.
The payload is now v2.<issued>.<scope>.<nonce>, HMAC-signed as a whole, where
the nonce is 16 random bytes per render. Three consequences:
- The scope is signed, not just a storage-key prefix. A token issued with
one scope can no longer be presented to another form — but it also means
issue()andverify()must be given the same scope.issue()defaults to'reg', so an adapter that calls a bareissue()and verifies with its own scope now fails every submission. Always pass the scope on both sides. - Same-second renders are distinct. The 1.5.4 collision (identical tokens within one second, single-use rejecting all but the first, a shared page cache handing one token to everybody) is gone.
- Scope is normalized to
[a-z0-9_-]afterstrtolower(), and other characters are stripped —my form!andmyformare the same scope. Pick a scope that is already in that alphabet.
The format version is signed too, so a token rendered by 1.5.4 does not verify on 1.5.6. Expect a burst of rejections from cached pages immediately after the upgrade; that is the intended fail-closed behaviour, not a defect.
The primitive is now a per-render, signed, form-bound proof of render. It is still not identity or a CAPTCHA: it proves a form was rendered, not who rendered it. For high-assurance proof, use a purpose-built protocol or an external challenge; do not hide the limitation behind an adapter name.
Minimal adapter pattern
Own the field names and require the honeypot to be present and empty. This is stricter than the built-in LW guards, where omission is accepted.
use LightweightPlugins\Firewall\Options;
use LightweightPlugins\Firewall\Rules\RegisterToken;
final class MyFormProof
{
private const TOKEN = 'myplugin_form_proof';
private const HONEYPOT = 'myplugin_company_url';
private const SCOPE = 'myplugin_contact_v1';
public static function issue(): array
{
if (!class_exists(RegisterToken::class)
|| !class_exists(Options::class)
|| !(bool) Options::get('enabled', true)
) {
return [];
}
return [
// Pass the scope: it is signed into the token in 1.5.6, and
// verify() below must be given the same one.
self::TOKEN => RegisterToken::issue(self::SCOPE),
self::HONEYPOT => '',
];
}
/** @param array<string, mixed> $input */
public static function validate(array $input): true|WP_Error
{
if (!class_exists(RegisterToken::class)
|| !class_exists(Options::class)
|| !function_exists('lw_firewall_resolve_storage')
) {
return true; // Change deliberately if this integration is mandatory.
}
if (!(bool) Options::get('enabled', true)) {
return true;
}
if (!array_key_exists(self::HONEYPOT, $input)
|| (string) $input[self::HONEYPOT] !== ''
) {
return self::failure();
}
$storage = lw_firewall_resolve_storage((string) Options::get('storage', 'auto'));
$token = sanitize_text_field((string) ($input[self::TOKEN] ?? ''));
$valid = RegisterToken::verify($token, 2, 3600, $storage, self::SCOPE);
return $valid ? true : self::failure();
}
private static function failure(): WP_Error
{
return new WP_Error(
'myplugin_form_rejected',
__('The form could not be submitted. Please try again.', 'myplugin'),
['status' => 400]
);
}
}
SCOPE must be a developer-owned constant, never request input. Since 1.5.6 it
is signed into the token as well as namespacing the atomic replay counter, so it
genuinely binds the proof to this form — which is also why issue() and
verify() must receive the identical value. Own timing values in the companion
plugin; borrowing register_* options silently couples unrelated forms to
registration policy.
Rendering and transport
- Server-rendered form: render both fields per response and escape attributes.
- REST/headless: deliver the proof in a private/no-store bootstrap response and
extract it from
WP_REST_Request; never useRegisterGuard::validate(). - AJAX: validate before any database, mail, CRM, or remote side effect.
- Full-page/CDN cache: do not cache one single-use token for all visitors.
An anonymous token-mint route is necessarily public. Give it a dedicated rate limit and do not treat successful minting as authentication or CSRF proof.
Separate rate limit
The token is not a request-volume control. Use RateLimiter::is_allowed_key()
with a form-specific key and IpDetector::get_ip(). Return the transport's own
generic 429 contract rather than calling RateLimiter::too_many() when a JSON
envelope is required.
The global protect_rest_api toggle is shared across all detected REST traffic
— since 1.5.6 that includes the pretty /wp-json/ prefix, the bare /wp-json
index and the ?rest_route= form — but it is one shared bucket and does not
know which route submits this form. It is not a substitute for a form-specific
limit.
RateLimiter::is_allowed_key() overrides the count limit only. Its window
still comes from the global rate_window option. A companion that needs an
independent window must call the selected storage's increment() with its own
bounded TTL or own a dedicated limiter abstraction.
Upstream extension proposal
For a first-class LW Firewall feature, do not turn the private, $_POST-bound
registration guard into a growing list of form-specific hooks. Add a
transport-neutral form-guard contract with:
- a registered, developer-owned form ID and server-owned policy;
- a random per-render nonce signed together with version, form ID and issue time;
- strict honeypot presence plus emptiness;
- atomic, form-bound replay consumption;
- array/request input instead of direct superglobal reads;
- a structured verdict that callers translate into their own HTML/JSON error;
- independent per-form limit and window settings;
- explicit storage-failure and full-page-cache behavior.
Read generic-form-guard-proposal.md for the proposed PHP contract, threat boundaries, hooks and acceptance tests. That document is a design proposal for a later LW Firewall version, not an API available in 1.5.6.
Layers the adapter must not replace
- capability/authentication checks for non-public actions;
- CSRF nonce for cookie-authenticated state changes;
- server-side schema, sanitization, and business validation;
- upload controls and output escaping;
- duplicate/idempotency controls for expensive side effects;
- email verification, CAPTCHA/bot scoring, or provider abuse controls where required.
Test matrix
- Valid, missing, filled, and omitted honeypot.
- Valid, tampered, too-young, expired, and replayed token.
- Two tokens issued in the same second for the same scope (must both succeed).
- Token minted for one adapter and submitted to another scope (must fail).
- A token issued without an explicit scope and verified with one (must fail — this is the common adapter bug after the 1.5.6 format change).
- A token in the pre-1.5.6 format (must fail closed).
- Shared-cache delivery to multiple anonymous clients.
- Plugin inactive, helper unavailable, master disabled, and chosen fail policy.
- Form-specific rate threshold and atomic concurrency.
- Form-specific window behavior; do not assume the current
RateLimiterowns it. - POST, AJAX, pretty REST, and
?rest_route=where supported. - One generic public error with no check-specific oracle.
Cross-references
- Use
lw-firewall-registration-guardfor user creation. - Use
lw-firewall-password-reset-protectionfor lost-password flows. - Use
lw-firewall-rate-limit-workerfor request detection and local limits. - Use
wp-rest-apiandwp-security-auditfor endpoint security.
References
- Official LW project: https://github.com/lwplugins/lw-firewall
- Verified LW plugin-root-relative sources:
includes/Rules/RegisterToken.phpincludes/Rules/RegisterGuard.phpincludes/Rules/PasswordResetGuard.phpincludes/Rules/RateLimiter.phpincludes/Storage/StorageInterface.phpincludes/Options.phpworker/lw-firewall-worker.php