Guests and spam protection
6 min read
Forms that visitors can send are attacked by bots within days. Every form of this plugin runs the checks below on its own, with no setup, and you can add a CAPTCHA on top.
The checks, in order
# | Check | Applies to | Message the visitor sees |
|---|---|---|---|
1 | License active | everyone | "This form is not available right now." |
2 | Honeypot | everyone | "Your entry could not be sent." |
3 | Security proof: signed token (guests) or nonce (logged in) | everyone | see below |
4 | Access rules | everyone | "Please log in to use this form." / "You are not allowed to use this form." |
5 | Rate limit | everyone | "Too many entries were sent from here. Please try again later." |
6 | Your CAPTCHA filter | everyone | your message, or "Your entry could not be verified. Please try again." |
7 | Duplicate guard | everyone | "This entry was already sent. Please wait a moment before sending it again." |
8 | Values, required fields, uploads | everyone | the error list |
Every refusal re-shows the form with the typed values and the box Your entry was not sent:.
Guests: the signed token
A visitor who is not logged in cannot use a WordPress nonce (nonces are per visitor, which would break page caching). Instead the form carries a token: the time the form was rendered, signed with your site's secret salt and the form's key. It is a hidden field, so you can see it in the page source.
- It must be at least 3 seconds old. A bot that submits instantly is refused: "The form was sent too quickly. Please wait a moment and send it again."
- It must be younger than 24 hours. Older, missing, damaged or foreign tokens (made for another form) give "The form has expired. Please check your entry and send it again."
- A script on the page asks the server for a fresh token (
GET /wp-json/wpmcf/v1/forms/{key}/token, a public route) when the one in the page is older than an hour, so a cached page keeps working for days. - Logged-in users send a nonce instead. If their session changed (logged out in another tab, for example) they see "Your session has changed. Please check your entry and send it again."
The page that contains a form can therefore be served from a page cache for guests. Make sure your cache does not serve a cached copy to logged-in visitors.
Honeypot
Every form has a hidden text box named wpmcf_form_website (labelled "Leave this field empty", hidden with CSS and from screen readers, not focusable). People never fill it; many bots fill every box. A submission with a value in it is refused before anything else.
Rate limit
By default a sender may have 5 accepted entries per form per 10 minutes. The sender is the logged-in user, or the visitor's IP address (stored only as a hash). Entries that are refused (a missing field, a wrong CAPTCHA, a bad file) do not count: a person correcting a typo is never locked out.
Change it with a filter, for example in a small plugin:
add_filter( 'wpmcf/forms/rate_limit', function ( array $limit, $form, int $user_id ): array {
// 20 entries per hour; use 'max' => 0 for no limit at all.
return [ 'max' => 20, 'window' => HOUR_IN_SECONDS ];
}, 10, 3 );max is the number of entries (0 = unlimited), window the number of seconds. The filter receives the form (a FormDTO) and the current user ID, so you can set different limits per form or per role.
The counters live in the object cache when you have a persistent one, otherwise in transients. Without a persistent object cache two requests at the very same moment can slip through together; for a strict limit use Redis or Memcached.
Behind a proxy or CDN: the visitor IP
The rate limit (and the duplicate guard) need the real visitor IP. By default the plugin uses the address that connects to your server (REMOTE_ADDR), because headers such as X-Forwarded-For can be written by the visitor and so cannot be trusted by default.
Behind a CDN or reverse proxy (Cloudflare, a load balancer, Varnish, a hosting proxy) REMOTE_ADDR is the proxy's address, so all visitors share one limit and the first five entries within ten minutes block everybody. Fix it in wp-config.php, above the line /* That's all, stop editing! */:
// Cloudflare
define( 'WPMCF_CLIENT_IP_HEADER', 'HTTP_CF_CONNECTING_IP' );
// A reverse proxy that sets X-Forwarded-For (the first address of the list is used)
define( 'WPMCF_CLIENT_IP_HEADER', 'HTTP_X_FORWARDED_FOR' );- The value is the name of the
$_SERVERentry: the header name in capitals, dashes as underscores, withHTTP_in front. - If the header holds a list (
203.0.113.9, 10.0.0.1) the first address is used. An invalid value falls back toREMOTE_ADDR. - Set it only when your proxy always overwrites that header with the real address. If a visitor can send the header themselves, they can pick any address and escape the limit.
- For full control use the filter
wpmcf/forms/client_ip, which receives the address and must return a valid IP:
add_filter( 'wpmcf/forms/client_ip', fn( string $ip ): string => $_SERVER['HTTP_X_REAL_IP'] ?? $ip );Duplicate guard
The same entry (same field values and file names/sizes) from the same sender to the same form within 30 seconds is refused with "This entry was already sent. Please wait a moment before sending it again." It stops double clicks and a refreshed "resubmit" dialog. A refused or failed submission releases the guard at once, so correcting and resending works. Without a persistent object cache the guard is not atomic: two requests at the very same moment can both pass.
Your own CAPTCHA
The plugin has no CAPTCHA of its own, but it asks you before saving. Return true to accept, false to refuse, or a WP_Error whose message is shown to the visitor:
add_filter( 'wpmcf/forms/verify_submission', function ( $ok, $form, array $request ) {
$token = $request['cf-turnstile-response'] ?? ''; // the field your CAPTCHA widget posts
if ( $token === '' || ! my_site_verify_turnstile( $token ) ) {
return new WP_Error( 'captcha', 'Please prove you are human.' );
}
return $ok;
}, 10, 3 );$formis the form definition (use$form->keyto protect only some forms).$requestis the posted data, unslashed and not sanitised: treat it as untrusted.- The filter runs after the built-in checks and after your rate limit slot is reserved; a refusal gives the slot back.
- The filter is the server-side check only. The plugin does not print a widget inside the form; your CAPTCHA integration must add its response field to the form's POST data (many CAPTCHA scripts attach to every form on a page by themselves).
wpmcf/forms/verify_submissionalso runs for logged-in users; return$okfor them if you do not want a CAPTCHA there.
Who may submit (access)
In the form editor, Who can submit limits a form to Logged-in users or to Users with a capability (a WordPress capability name such as edit_others_posts). Access is checked when the form is shown and again when it is sent, so a tampered request cannot get around it. For Post forms the visitor needs, in addition, WordPress's permission to edit the post when editing. See Create a form.
What is never trusted from the visitor
- A post or user ID sent with the form: a profile form uses the login session; a post is edited only when WordPress allows that user to edit it.
- An attachment ID for an Image or File field: only the file the entry already has can be kept; any other ID is ignored, so a visitor cannot attach somebody else's media.
- A role for a registration: it is always the one set in the form.
- The return address after sending: it must be on your site.
- Passwords are never sent back to the browser and are stored as hashes.
For developers
wpmcf/forms/rate_limit,wpmcf/forms/client_ip,wpmcf/forms/verify_submission: described above.- The constant
WPMCF_CLIENT_IP_HEADER: described above. - The token route is
GET /wp-json/wpmcf/v1/forms/{key}/tokenand answers{"token": "..."}for an active form only.
Still stuck? Contact support