Design 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, app...
Scanned 9/5/2026
Install to Claude Code
npx -y skills add Lonsdale201/wp-agent-skills --skill lw-firewall-custom-form-adapter --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Lw Firewall Custom Form Adapter?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/lonsdale201-lw-firewall-custom-form-adapter)More formats (shields.io, HTML) on the badges page.
---
name: lw-firewall-custom-form-adapter
description: Design 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.
metadata:
wp-skills-author: "Soczó Kristóf"
wp-skills-contact: "mailto:lonsdale201@hotmail.com"
wp-skills-plugin: "lw-firewall"
wp-skills-plugin-version-tested: "1.5.6"
wp-skills-wp-version-tested: "7.1"
wp-skills-php-min: "8.2"
wp-skills-last-updated: "2026-08-29"
---
# 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()` and `verify()` **must be given the same scope**. `issue()` defaults
to `'reg'`, so an adapter that calls a bare `issue()` 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_-]` after `strtolower()`, and other
characters are stripped — `my form!` and `myform` are 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.
```php
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 use `RegisterGuard::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](references/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 `RateLimiter` owns 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-guard` for user creation.
- Use `lw-firewall-password-reset-protection` for lost-password flows.
- Use `lw-firewall-rate-limit-worker` for request detection and local limits.
- Use `wp-rest-api` and `wp-security-audit` for endpoint security.
## References
- Official LW project: <https://github.com/lwplugins/lw-firewall>
- Verified LW plugin-root-relative sources:
- `includes/Rules/RegisterToken.php`
- `includes/Rules/RegisterGuard.php`
- `includes/Rules/PasswordResetGuard.php`
- `includes/Rules/RateLimiter.php`
- `includes/Storage/StorageInterface.php`
- `includes/Options.php`
- `worker/lw-firewall-worker.php`
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!