LW Firewall custom-form adapter
SkillMediaDesign or audit an LW Firewall adapter for a custom public form that is not the built-in registration or lost-password form, or specify a future generic form-guard extension contract without claiming it already exists. Covers the absence of a generic guard, reusable `RegisterToken` and `RateLimiter` primitives, strict honeypot presence, developer-owned replay scopes, REST/headless transport, shared-cache safety, fail-open policy, and route-local abuse limits. Use for contact, lead, quote, application, custom signup, AJAX, or REST forms mentioning LW Firewall honeypots, timing tokens, proof fields, custom anti-bot adapters, or a generic form-protection API.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the LW Firewall custom-form adapter skill
What this skill tells your AI
The instructions your AI receives, as published by lonsdale201/wp-agent-skills in lw-plugins/lw-firewall-custom-form-adapter/SKILL.md and read by ahel’s review.
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
Signals
- GitHub stars
- 22
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
lw-firewall-custom-form-adapter- Source
- github.com/lonsdale201/wp-agent-skills