Protect Login Forms for WordPress & WooCommerce
Asks visitors to type the characters shown in an image before a login, registration, lost-password, comment or product review form is processed. The image is drawn on your own server and each code works once.
Version 1.2 WordPress plugin Paid plugin Requires WordPress 6.0 Requires PHP 7.4 Tested up to 7.1 Updated 6 Oct 2026
1. Overview
Protect Login Forms for WordPress & WooCommerce asks visitors to type the characters shown in a small image before a form is processed. Bots that cannot read the image are refused before they create a fake account, post a comment or try a password. The image is drawn on your own server with PHP GD and a bundled font. There is no captcha account, no API key and no third-party service, and the plugin sets no cookies.
The plugin protects eight forms: four from WordPress (login, registration, lost password, guest comments) and four from WooCommerce (My Account and classic checkout login, My Account registration, My Account lost password, guest product reviews). Every form has its own switch, and all eight are on from the moment you activate the plugin.
The settings screen is called Protect Login Forms and sits under Settings → Protect Login Forms. The plugin's folder, its filters and its code use the short name wphub-stop-spam (the filters are called wphub_stop_spam_*).
How it works
- When a protected form is shown, the plugin creates a signed token and draws a PNG image of a 5-character code. The image is placed in the page as a
data:address, and the token goes into a hidden field calledverification_nonce. - The visitor types the code into a text box under the image.
- When the form is submitted, the plugin checks the token and the typed code before WordPress or WooCommerce processes the form. A missing, wrong, expired or already used code stops the form with a message.
- The answer is never stored and never sent to the browser. It is worked out again from the token, using your site's own secret keys. Each token works once, whether the code was right or wrong.
At a glance
Eight forms, eight switches
Login, registration, lost password and guest comments in WordPress; login, registration, lost password and guest reviews in WooCommerce. All on by default.
Single-use codes
A code works once, even when many requests carrying it arrive at the same moment, and a wrong guess cannot be retried with the same code.
Signed, tied to one form
A login code is useless on the registration form. Codes expire after an hour, or six hours on comment and review forms.
Self-hosted
No external request, no cookie, no IP address or personal data stored. Only a short anonymous marker is kept until a code expires.
Cannot lock you out
If the server has no GD with FreeType, the plugin switches itself off and says so on its settings screen.
Usable by other code
A theme or plugin can show and check the same code on its own form through a small PHP interface and four filters.
What it does not do
- It does not stop determined attackers. An image code stops automated bots that cannot read it, but OCR, AI or human solving services can defeat any image captcha. Combine it with strong passwords and a login-attempt limiter.
- It has no audio alternative for visitors who cannot read the image.
- It does not cover trackbacks and pingbacks, which have no form. Turn them off under Settings → Discussion if you receive spam that way.
- It does not cover account creation during WooCommerce checkout, the Cart and Checkout blocks' own forms, or any form that is not one of the eight listed on this page.
- It does not limit login attempts, lock accounts, block IP addresses or log anything.
- It adds no shortcodes, blocks, REST routes, AJAX actions, WP-CLI commands, widgets, scripts or style sheets.
2. Requirements
| Item | Requirement | Notes |
|---|---|---|
| WordPress | 6.0 or later | From the plugin header (Requires at least). Tested up to 7.1 (the header and readme). This page was checked against WordPress 7.1.2. |
| PHP | 7.4 or later | From the plugin header (Requires PHP). WordPress itself refuses to activate a plugin on an older PHP version. |
| PHP GD with FreeType | Required for the image | The plugin needs the GD functions imagecreatetruecolor, imagettftext, imagettfbbox and imagepng, a readable bundled font (assets/fonts/roboto-black.ttf), and FreeType that really renders text. If any of that is missing the plugin switches itself off (see When the server cannot draw the image). |
| PHP intl extension | Optional | If present, full-width characters from a CJK keyboard are folded to the plain character before the code is compared. Without it the plugin works the same, but such characters are not folded. |
| WooCommerce | Optional | Needed only for the four WooCommerce switches. The header says tested up to WooCommerce 11.1. The plugin declares compatibility with High-Performance Order Storage and the Cart and Checkout blocks, because it never reads or writes orders, products or carts. |
| JavaScript | Not used | The plugin loads no script and no style sheet. The image is part of the page HTML. |
| Page caching | Exclude the form pages | Pages that show a code mark themselves as not cacheable for plugins that honour DONOTCACHEPAGE. If a CDN or server cache stores such a page anyway, exclude the login, My Account and comment pages from it. |
3. Installation
- Download the plugin zip from your account on wpexpertshub.com.
- In WordPress open Plugins → Add New → Upload Plugin, choose the zip and click Install Now. You can also upload the
wphub-stop-spamfolder to/wp-content/plugins/. - Click Activate. A Settings link and a Licence link appear under the plugin name on the Plugins screen.
- Open Settings → Protect Login Forms and untick any form you do not want to protect.
- Log out and open your login, registration or comment form to see the code.
- Optional but recommended: activate your licence to receive updates (see below).
What activation creates
Nothing. There is no activation hook, so activating creates no tables, no options, no cron events, no roles and no pages. The code is switched on for all eight forms straight away, because every form is on by default when nothing has been saved. The option wphub_stop_spam_settings is written the first time you save the settings screen.
Licence activation and updates
The plugin bundles the WpExperts Hub licence and update client. The plugin works as soon as it is activated. No feature of the plugin checks the licence. The key only matters for updates: the update package link comes from the licence server, and without an active licence the Plugins screen shows Automatic update is unavailable for this plugin. Activate your licence to enable updates. under an available update.
- Open Plugins → WpExperts Hub Licences. The same screen opens from the Licence link under the plugin name and from the Manage licences link in the notice on the Plugins screen. It needs the
manage_optionscapability and lists every WpExperts Hub plugin active on the site. - Paste the key from your purchase email (it is also shown under My Account → Downloads on wpexpertshub.com) into the box labelled Licence key and click Activate licence. Characters other than letters, digits and hyphens are removed from the key before it is sent.
- The card for the plugin then shows Active and the key with its middle masked, with a Deactivate licence button. New versions appear on Dashboard → Updates and on the Plugins screen and install like any other plugin update.
- Can't find your key? Email it to me. Open this fold-out on the licence screen, enter the email address used for the purchase and click Send licence key. It asks wpexpertshub.com to email the key. The message you see comes from the server.
- Moving to another site. The screen says: Deactivate before moving the plugin to another live site. Click Deactivate licence. The licence is removed from this site even when the licence server cannot be reached; the screen then shows Licence removed from this site.
- If the server reports the licence as no longer active on this site, the card changes to Not active at the next update check. The key stays stored, so you can activate it again.
- Update information is cached for 12 hours (one batched request for all WpExperts Hub plugins on the site). If the licence server cannot be reached or answers badly, the previous information is kept and the plugin tries again after one hour. The cache is cleared after any plugin update and after you activate, deactivate or request a key.
- When the client loads. Only in wp-admin, during WP-Cron runs and under WP-CLI. Front-end requests do not load it, so a visitor looking at a login form never triggers a licence request.
What is sent to wpexpertshub.com, and when, is listed under Privacy and data.
Updating
With an active licence, update from Dashboard → Updates or the Plugins screen. Without one, upload the new zip under Plugins → Add New → Upload Plugin and choose to replace the current version. Either way your saved settings are kept. Version 1.2 changed how codes are checked (see the Changelog): after updating from 1.0, open Settings → Protect Login Forms, because all eight forms, including comments and product reviews, are on by default.
Multisite
The settings and the licence key are stored per site, so each site of a network has its own Settings → Protect Login Forms screen. When the plugin is deleted, uninstall.php goes through every site of the network and removes the settings, the single-use markers and the housekeeping transients on each (see Deactivation and uninstall).
4. Quick start
- Activate the plugin. Check that Settings → Protect Login Forms shows no yellow notice about the image library.
- Tick the forms that should ask for a code and click Save Changes. All eight are ticked until you change them.
- Open a private browser window (so you are logged out) and visit your login form. A small image, a text box and the line Authenticate yourself, you're human. appear.
- Type the code exactly as shown, with capital and small letters as drawn, and log in.
- If you use a page cache or a CDN, exclude the login, My Account and comment pages from it.
wp-login.php without showing the code field, logins from that form are refused. Test every login form you use while you are still logged in elsewhere. If you are locked out, rename the plugin folder, log in, rename it back and switch that form off (see If you are locked out).5. Features
How a code is made and checked
Making the code
- The plugin builds a token of three parts joined by dots: an expiry time (a Unix timestamp), 16 random hexadecimal characters, and a signature. The signature is an HMAC-SHA256 of the form, the expiry time and the random part, made with your site's
noncesalt (wp_salt( 'nonce' )). The token is placed in the hidden fieldverification_nonce. - The code is worked out from the same data with a second HMAC-SHA256, so it is never stored and never sent to the browser. Each position is one of 32 characters. The default length is 5 (the filter
wphub_stop_spam_code_lengthallows 4 to 8). - The 32 characters are the capitals A B D E F G H J M N Q R T, the small letters a b d e f g h j n q r t y and the digits 2 3 4 5 6 7. Characters that are easy to confuse are left out: I, l, 1, O, 0, and letters whose capital and small forms look alike (c, k, o, p, s, u, v, w, x, z). Where two characters look alike, only one is used: small m is left out because it can look like rn, 8 because it can look like B, 9 because it can look like g, and capital Y because it can look like small y.
- The code is drawn as a 112 by 44 pixel PNG: a light random background, six random lines and fifty random dots behind the text, each character drawn on its own at a slightly different size (17 to 19), angle (up to 12 degrees either way) and height, and two lines struck across the text. The randomness only changes how the picture looks; the answer comes from the token.
- The PNG is put in the page as
data:image/png;base64,…, so the page makes no separate image request. The image has the description Image containing the authentication code to type below. - The token is tied to one form. A code made for the login form fails on the registration form. Comment forms and product review forms share one binding, so a code from the comment form also passes on a review form (and the other way round).
- A page that shows a code defines the constant
DONOTCACHEPAGE, so page-cache plugins that honour it do not keep the page.
A code is valid for 1 hour (3,600 seconds). On comment and review forms it is valid for 6 hours (21,600 seconds), so a long comment is not lost. The filter wphub_stop_spam_ttl changes this, with a minimum of 60 seconds. The expiry is part of the signed token, so a change to the filter applies to codes made after the change.
Checking the code
- The plugin reads the token from
verification_nonceand the typed code from the field that belongs to the form (see The protected forms). - What the visitor typed is cleaned: spaces, tabs, line breaks and invisible characters that come along when a code is copied (non-breaking space, zero-width space, word joiner, byte order mark) are removed, full-width forms are folded to plain characters when the PHP
intlextension is available, and at most 32 characters are kept. Nothing is ever changed to upper or lower case. If nothing is left, the check stops with Please enter the authentication code shown in the image. - The token must have exactly three parts: digits, 16 lower-case hexadecimal characters and 64 lower-case hexadecimal characters. It must not have expired, and its signature must match what the plugin makes for this form.
- The token is then claimed: the plugin inserts one row in the options table named
wphub_ss_used_followed by the first 40 characters of the signature. This insert is atomic, so when many requests carry the same token, exactly one claims it. A token that was claimed before is refused. - Only then is the typed code compared with the code worked out from the token. The comparison is exact, character for character and case for case:
aB4is notAB4. - Any failure gives the same message: The authentication code entered is invalid or has expired. Please try again. Because the token is claimed before the comparison, a wrong code also uses the token up, so a guess cannot be retried with the same image.
- If the same request runs the check more than once with the same token and the same typed code (another plugin calling
wp_authenticate()early and thenwp_signon()running it again), the first answer is reused. The token is spent once per request, and a token reused in a later request is still refused.
Marker rows are removed once their expiry time has passed. A clean-up runs when a token is claimed and at least an hour has passed since the last clean-up.
The protected forms
The plugin protects these eight forms. Each is switched on or off by its own checkbox (see Settings) and by the filter wphub_stop_spam_enabled. A form that is switched off is never touched: no code is shown and nothing is checked.
| Form (slug) | Where the code appears | Where it is checked | Typed code field |
|---|---|---|---|
Login form (wp_login) | The login form on wp-login.php, and forms made with wp_login_form() (filter login_form_middle). | Filter authenticate at priority 99, for any request that posts a log field. | verification_admin |
Registration form (wp_register) | The registration form on wp-login.php?action=register (action register_form). WordPress shows that form only when Settings → General → Anyone can register is ticked. | Filter registration_errors, only for a POST to wp-login.php. | verification_admin |
Lost password form (wp_lostpassword) | The lost-password form on wp-login.php (action lostpassword_form). | Filter lostpassword_errors, only for a POST to wp-login.php. | verification_admin |
Comment form (guests only) (comment) | In front of the submit button of comment_form(), for visitors who are not logged in, on every post type except product. | Action pre_comment_on_post. | verification_comment |
My Account / checkout login form (woocommerce_login) | The login form on the My Account page and the login form on the classic checkout page (action woocommerce_login_form). WooCommerce shows the checkout login form to logged-out customers only when WooCommerce → Settings → Accounts & Privacy has Enable log-in during checkout or account creation During checkout ticked; both are off by default in WooCommerce 11.1.2. | Filter woocommerce_process_login_errors. | verification_login |
My Account registration form (woocommerce_register) | The registration form on the My Account page (action woocommerce_register_form). | Filter woocommerce_process_registration_errors. | verification_register |
My Account lost password form (woocommerce_lostpassword) | The lost-password form on the My Account page (action woocommerce_lostpassword_form). | Action lostpassword_post at priority 9, when WooCommerce's hidden field wc_reset_password is posted. | verification_lostpassword |
Product review form (guests only) (woocommerce_review) | The same comment form as above, on posts of the type product, for visitors who are not logged in. | Action pre_comment_on_post. | verification_comment |
Every form also posts the hidden field verification_nonce with the signed token.
Login, registration and lost password on wp-login.php
- Login. On a wrong or missing code the plugin returns an error with the code
captcha_errorand the message Error: …. The error replaces whatever the earlier checks decided: the check runs at priority 99, after WordPress has already compared the password, but it never reveals whether the password was right. A right code with a right password logs in as usual. A right code with a wrong password shows WordPress's own error, and the code is spent. In WordPress 7.1.2 a failed sign-in other than an empty user name or password fireswp_login_failed, so a login limiter counts a wrong code as a failed attempt. - Which requests are checked. Any request that reaches WordPress's sign-in check with a posted
logfield. The WooCommerce login form postsusername, notlog, so it is handled by its own switch. XML-RPC and REST sign-ins do not postlogand are not affected. - Registration and lost password. The check is tied to the request being a POST to
wp-login.php, not to a particular submit button, so a bot that leaves out the button is still checked. - Forms made with
wp_login_form(). Themes and shortcodes that print the login form withwp_login_form()get the code automatically, because the plugin adds it throughlogin_form_middle. That form posts towp-login.php.
Comments
- Who sees it. Only visitors who are not logged in. Logged-in users are never asked on the comment and review forms.
- Where it appears. In front of the submit button. The plugin learns which post the form is for from
comment_id_fields(the post thatcomment_form()was given, which can differ from the global post in block themes), then adds the code throughcomment_form_submit_field. Posts of the typeproductuse the product review switch; every other post type uses the comment switch. - Where it is checked. At
pre_comment_on_post, which WordPress 7.1.2 fires insidewp_handle_comment_submission()(used bywp-comments-post.php) after it has checked that the post exists, has comments open, is not trashed or a draft and is not password-protected, and before it checks the name, email and comment fields. A comment sent by something that never calls this function (for example a plugin that inserts comments directly) is not checked. - The code is spent first. Because the check comes before WordPress's other validation, a visitor who left a required field empty has already used the code. The visitor has to reload the page for a new image.
- On failure WordPress shows a plain page titled Comment verification failed with the message and the text Copy your comment, reload the page to get a new code and submit it again., with a Back link. The page is sent with HTTP status 200, like core's other fixable comment errors, because some hosts replace error pages.
- Themes with their own comment form. The check does not depend on the field being shown. If a theme builds the comment form without
comment_form()and does not show the code, guest comments are refused with Please enter the authentication code shown in the image. Switch the comment switch off, or have the theme show the code through the interface in The PHP interface.
Guest comments through the REST API and XML-RPC
While either the comment or the product review switch is on, the plugin forces the filters rest_allow_anonymous_comments and xmlrpc_allow_anonymous_comments to false at priority 999, because those routes cannot show a code. Any earlier filter that allowed guest comments is overruled. In WordPress 7.1.2 both filters already default to false, so guests cannot comment through the REST API or XML-RPC unless some other code switched them on; the plugin makes sure that stays off. Logged-in users are not affected. If the image library is missing, the plugin does nothing here.
WooCommerce
- Login. The code appears on the My Account login form and on the login form of the classic checkout page, because WooCommerce fires
woocommerce_login_formin both. In WooCommerce 11.1.2 the checkout login form is shown to logged-out customers only when WooCommerce → Settings → Accounts & Privacy has Enable log-in during checkout ticked, or account creation During checkout is ticked; both are off by default, so on a default store the code appears only on My Account. A wrong code stops the login with WooCommerce's usual Error: notice. - Registration and lost password. My Account only. A wrong code on registration shows as a WooCommerce error notice; on lost password it blocks the reset email and shows the message as a notice. WooCommerce shows the registration form only when customer registration is allowed on the My Account page.
- Reviews. Reviews are WordPress comments on posts of the type
product, so they use the comment mechanism above with their own switch. - Not covered. Creating an account during checkout is not a separate form and is not checked. The Cart and Checkout blocks do not use the classic checkout template (WooCommerce 11.1.2 fires
woocommerce_before_checkout_form, which prints the login form, only from the classic checkout template), so no code is shown there. The plugin only declares compatibility with them. - Templates. The code field of the three My Account forms comes from templates you can override in your theme (see Templates).
- HPOS. The plugin declares compatibility with High-Performance Order Storage (
custom_order_tables) and the Cart and Checkout blocks (cart_checkout_blocks) atbefore_woocommerce_init, and does nothing else with orders, products or carts.
When WooCommerce is not active, the four WooCommerce checkboxes are still saved and the settings screen says WooCommerce is not active; these settings apply as soon as it is.
When the server cannot draw the image
The plugin checks that GD has imagecreatetruecolor, imagettftext, imagettfbbox and imagepng, that the bundled font file is readable, and that FreeType really renders text (it measures the letter A). If any of that fails, the plugin treats every form as switched off. No code is shown, nothing is checked, nothing is blocked, and guest comments through the REST API and XML-RPC are not blocked either. Your forms keep working. The settings screen then shows a yellow notice:
The image library (PHP GD with FreeType) is not available on this server, so no verification code is shown and nothing is blocked. Ask your host to enable GD with FreeType support.
The filter wphub_stop_spam_supported can override the check. Return false to switch the whole plugin off, for example on a staging site. Do not return true on a server that has no GD and FreeType: the image cannot be drawn there.
Caching and performance
- A new token and image are made every time a protected form is shown. Nothing is stored for them until the form is submitted.
- Whenever a code is made, the constant
DONOTCACHEPAGEis defined, which page-cache plugins that honour it use to skip the page. This includes every post or product page that shows the comment or review form to a logged-out visitor, not only the login pages. If you want those pages cached, switch the comment and review switches off. - A cache that ignores the constant keeps a code that is single-use and expires, so the cached form fails for visitors with The authentication code entered is invalid or has expired. Please try again. Exclude the login, My Account and comment pages from CDN and server caches.
- Each check that reaches the claim step writes one small row to the options table (see Options, transients and constants).
Messages visitors see
| Message | When |
|---|---|
| Authenticate yourself, you're human. | The label above the code on every form. |
| Type the code exactly as shown: capital and small letters are different. | The hint under the text box. |
| Please enter the authentication code shown in the image. | The box was empty (or contained only spaces and invisible characters). |
| The authentication code entered is invalid or has expired. Please try again. | A wrong, expired, already used, damaged or missing token or code. |
| Comment verification failed | The title of the page shown when the comment check fails (with the message above and Copy your comment, reload the page to get a new code and submit it again.). |
On wp-login.php these appear as WordPress login errors, and on the WooCommerce forms as WooCommerce notices. The text box has required, autocomplete="off", autocapitalize="off", autocorrect="off", spellcheck="false" and a maximum of 12 characters, so phones do not force capitals.
When each feature arrived
Version 1.0 was the first release. Version 1.2 (2026-10-03) added the settings screen with a switch for every form, the WordPress registration form, guest comments, WooCommerce product reviews and wp_login_form() forms, signed single-use codes tied to one form, case-sensitive matching, the refusal of guest comments through the REST API and XML-RPC, the self-switch-off without GD and FreeType, and the four filters. Everything on this page describes version 1.2.
The settings screen
Open Settings → Protect Login Forms (options-general.php?page=wphub-stop-spam). It needs the manage_options capability. The Settings link under the plugin name on the Plugins screen opens it. The screen shows, in order: a yellow notice if the image library is missing, the sentence Choose the forms that ask visitors to type the code shown in an image. Each code works once and expires after an hour (six hours on the comment form)., and one row of checkboxes for WordPress and one for WooCommerce. A grey note under the WordPress row repeats the comment-form rules, and a grey note under the WooCommerce row appears only when WooCommerce is not active. Save Changes saves, and WordPress shows its usual Settings saved. notice.
6. Settings
All settings are on Settings → Protect Login Forms: one checkbox per form, in two groups. They are stored together in one option, wphub_stop_spam_settings. When you save, the plugin stores an explicit 1 (ticked) or 0 (unticked) for each of the eight forms. Until the first save, nothing is stored and every form counts as on. Later, a form missing from the stored option falls back to on.
Forms
| Setting (label) | Key | Default | What it does |
|---|---|---|---|
| WordPress: Login form | wp_login | On | Asks for the code on the wp-login.php login form and on forms made with wp_login_form(). |
| WordPress: Registration form | wp_register | On | Asks for the code on the WordPress registration form. |
| WordPress: Lost password form | wp_lostpassword | On | Asks for the code on the WordPress lost-password form. |
| WordPress: Comment form (guests only) | comment | On | Asks visitors who are not logged in for the code when they comment. Also refuses guest comments through the REST API and XML-RPC while on. |
| WooCommerce: My Account / checkout login form | woocommerce_login | On | Asks for the code on the My Account login form and the classic checkout login form. |
| WooCommerce: My Account registration form | woocommerce_register | On | Asks for the code on the My Account registration form. |
| WooCommerce: My Account lost password form | woocommerce_lostpassword | On | Asks for the code on the My Account lost-password form. |
| WooCommerce: Product review form (guests only) | woocommerce_review | On | Asks visitors who are not logged in for the code when they review a product. Shares its token binding with the comment form, but is switched on and off separately. Also refuses guest comments through the REST API and XML-RPC while on. |
Code lifetime and length
These are not settings on the screen. They are set in code with filters, because they are rarely changed:
| What | Default | Range | How to change it |
|---|---|---|---|
| Code lifetime | 1 hour (3,600 seconds); 6 hours (21,600 seconds) on comment and review forms | 60 seconds or more | The filter wphub_stop_spam_ttl. |
| Code length | 5 characters | 4 to 8 | The filter wphub_stop_spam_code_length. |
| A form on or off in code | As saved | true or false | The filter wphub_stop_spam_enabled. |
| Image library check | Detected | true or false | The filter wphub_stop_spam_supported. |
7. Reference for developers
The plugin registers no REST routes, no AJAX or admin-post actions, no shortcodes, blocks or WP-CLI commands. What other code relies on is: the posted field names, the checks hooked into WordPress and WooCommerce (see Hooks the plugin attaches to), a small PHP interface (The PHP interface) and four filters (Filters).
Form fields and where they are posted
| Field | Sent by | Meaning |
|---|---|---|
verification_nonce | Every protected form (hidden) | The signed token, expiry.random.signature. |
verification_admin | Login, registration and lost-password forms on wp-login.php, and wp_login_form() | The code the visitor typed. Its id is the same. |
verification_comment | Comment forms and product review forms | The typed code. |
verification_login | WooCommerce login form | The typed code. |
verification_register | WooCommerce registration form | The typed code. |
verification_lostpassword | WooCommerce lost-password form | The typed code. |
These names are what the plugin has always posted. A form built by other code that posts them to the usual handler (for example wp-comments-post.php) is checked by the plugin without further work. A visitor's token and code are read only from $_POST, as strings.
The PHP interface
The main file defines the function wphub_stop_spam(), which returns the single WpHub_Stop_Spam object. Use function_exists( 'wphub_stop_spam' ) first, because the plugin may be inactive. These public methods are the interface other code uses:
| Call | Returns | What it does |
|---|---|---|
WpHub_Stop_Spam::forms() (static) | array | The eight forms, keyed by slug. Each entry has group (wordpress or woocommerce), field (the typed-code field name), default (1), label and, for the review form, bind (comment). |
wphub_stop_spam()->is_supported() | bool | Whether the server can draw the image (the filter wphub_stop_spam_supported applies). |
wphub_stop_spam()->is_enabled( string $form ) | bool | Whether a form is on: its slug is known, the image library works, the saved setting (or the default) is on, and the filter wphub_stop_spam_enabled agrees. |
wphub_stop_spam()->get_field_data( string $form ) | array or null | Makes a fresh token and image for a form. The array has field (the field name for the typed code), token (the value for verification_nonce) and image (a data:image/png;base64,… address). Returns null when the form is off, the library is missing or the image could not be drawn. Defines DONOTCACHEPAGE. |
wphub_stop_spam()->get_verification_key( string $form ) | string | A fresh token only, or an empty string. It makes its own image that you never see. |
wphub_stop_spam()->verification_image( string $form ) | string | A fresh image address only, or an empty string. It makes its own token that you never see. |
wphub_stop_spam()->verify( string $form ) | true or WP_Error | Checks the posted token and code for a form, as described under How a code is made and checked, and spends the token. The error codes are wphub_stop_spam_empty (nothing typed) and wphub_stop_spam_invalid (anything else, including an unknown slug). Repeating the same call in one request gives the same answer. |
get_field_data(), get_verification_key() and verification_image() makes a new token and a new image. Calling get_verification_key() and verification_image() separately gives a token and an image that do not belong together. Use get_field_data() and take both from the same array.Form slugs are fixed: wp_login, wp_register, wp_lostpassword, comment, woocommerce_login, woocommerce_register, woocommerce_lostpassword, woocommerce_review. There is no hook to add a ninth form. Custom forms reuse one of the eight, for example comment or woocommerce_review.
Example: show the code on your own review form
This is the pattern a theme with its own product review form uses. The WpExperts Hub store theme does it this way: it fetches a fresh code from a small REST route of its own, with a POST request so that no cache keeps it, shows the image, and posts the form to wp-comments-post.php, where the plugin does the check.
add_action( 'rest_api_init', function () {
register_rest_route( 'myshop/v1', '/review-code', array(
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => function () {
if ( ! function_exists( 'wphub_stop_spam' ) || ! wphub_stop_spam()->is_enabled( 'woocommerce_review' ) ) {
return array( 'available' => false );
}
$data = wphub_stop_spam()->get_field_data( 'woocommerce_review' );
if ( ! $data ) {
return array( 'available' => false );
}
return array(
'available' => true,
'img' => $data['image'],
'token' => $data['token'],
);
},
) );
} );
In the form, put the returned img in an <img>, the returned token in <input type="hidden" name="verification_nonce">, and add a text input named verification_comment (the value of field for the review form). The comment goes to wp-comments-post.php, so the plugin verifies it at pre_comment_on_post. For a form that posts somewhere else, check the code yourself before doing anything else:
$result = wphub_stop_spam()->verify( 'comment' );
if ( is_wp_error( $result ) ) {
wp_die( esc_html( $result->get_error_message() ) );
}
Filters
The plugin fires four filters and no actions.
| Filter | Parameters | When and what |
|---|---|---|
wphub_stop_spam_enabled | bool $enabled, string $form | Applied each time the plugin asks whether a form is on, after the saved setting (or default) has been read. Return false to skip a form, true to force it on. It is not applied for an unknown slug or when the image library is missing (those are always off). The slugs are listed above. |
wphub_stop_spam_ttl | int $seconds, string $form | Applied when a token is made. Default 3600, or 21600 for comment and review forms. The result is raised to at least 60. $form is the binding the token is signed for, so for a product review form it is comment. |
wphub_stop_spam_code_length | int $length | Default 5, clamped to 4 to 8. It is read both when the image is drawn and when the answer is checked, so the filter must return the same number on both requests. |
wphub_stop_spam_supported | bool $supported | The result of the image library check. Return false to switch the plugin off everywhere. |
// Skip the code on a staging site.
add_filter( 'wphub_stop_spam_supported', function ( $supported ) {
return 'staging' === wp_get_environment_type() ? false : $supported;
} );
// Allow the login form code to live for 10 minutes only.
add_filter( 'wphub_stop_spam_ttl', function ( $seconds, $form ) {
return 'wp_login' === $form ? 600 : $seconds;
}, 10, 2 );
// Use 6-character codes.
add_filter( 'wphub_stop_spam_code_length', function () {
return 6;
} );
Hooks the plugin attaches to
These are the core and WooCommerce hooks the plugin uses. Where the plugin replaces a result, it says so.
| Hook | Priority, arguments | What the plugin does |
|---|---|---|
login_form (action) | 10 | Prints the code on the wp-login.php login form. |
login_form_middle (filter) | 10 | Appends the code to the output of wp_login_form(). |
register_form (action) | 10 | Prints the code on the registration form. |
lostpassword_form (action) | 10 | Prints the code on the lost-password form. |
authenticate (filter) | 99, 3 arguments | When a log field was posted and the login form is on, checks the code. On failure it returns a WP_Error with the code captcha_error, replacing the result of every earlier handler, including a valid user. |
registration_errors (filter) | 10, 3 arguments | On a POST to wp-login.php, adds the verification error to the errors object. |
lostpassword_errors (filter) | 10, 2 arguments | On a POST to wp-login.php, adds the verification error. |
comment_id_fields (filter) | 10, 2 arguments | Remembers which post the comment form is for. The fields are returned unchanged. |
comment_form_submit_field (filter) | 10, 2 arguments | For visitors who are not logged in, puts the code in front of the submit field. |
pre_comment_on_post (action) | 10 | Checks the code for the comment or review form. On failure it ends the request with wp_die(). |
rest_allow_anonymous_comments, xmlrpc_allow_anonymous_comments (filters) | 999 | Return false while the comment or review code is on, overruling any earlier filter. Otherwise the incoming value is returned. |
woocommerce_login_form, woocommerce_register_form, woocommerce_lostpassword_form (actions) | 10 | Print the code through the three overridable templates. |
woocommerce_process_login_errors (filter) | 10, 3 arguments | Adds the verification error for the WooCommerce login form. |
woocommerce_process_registration_errors (filter) | 10, 4 arguments | Adds the verification error for the WooCommerce registration form. |
lostpassword_post (action) | 9, 2 arguments | For the WooCommerce lost-password form only (when wc_reset_password is posted), adds the verification error to the errors object WooCommerce then reads. |
before_woocommerce_init (action) | 10 | Declares compatibility with custom_order_tables and cart_checkout_blocks, when WooCommerce's features class exists. |
init (action) | 10 | Registers the licence client (only in wp-admin, cron and WP-CLI). |
admin_menu, admin_init, plugin_action_links_<basename> | 10 | Add the settings screen, register its option and add the Settings link (admin only). |
Templates and markup
| File | Used for | Override |
|---|---|---|
templates/woocommerce-login.php | The code field on the WooCommerce login form | Copy to yourtheme/woocommerce/woocommerce-login.php |
templates/woocommerce-register.php | The code field on the WooCommerce registration form | Copy to yourtheme/woocommerce/woocommerce-register.php |
templates/woocommerce-lostpassword.php | The code field on the WooCommerce lost-password form | Copy to yourtheme/woocommerce/woocommerce-lostpassword.php |
templates/wp-fields.php | The code field on wp-login.php, wp_login_form() and comment forms | Not overridable: the plugin includes it directly. Style it with CSS. |
The three WooCommerce templates are loaded with WooCommerce's wc_get_template(), so a copy in yourtheme/woocommerce/ wins. They receive three variables: $field (the name and id of the code input), $token (the value for verification_nonce) and $image (the data: address). Keep the hidden field verification_nonce and a text input named $field, or the form cannot pass the check. The default markup is a paragraph with the classes wphub-stop-spam (and login-verification-code on wp-login.php and wp_login_form(), comment-form-wphub-stop-spam on comment forms), a label, the hidden token, an <img> of 112 by 44 pixels with a small inline style, the text input, and a hint with the class wphub-stop-spam-hint.
Options, transients and constants
| Name | Kind | Contents |
|---|---|---|
wphub_stop_spam_settings | Option | An array with one entry per form slug, 1 or 0. Written when the settings screen is saved. |
wphub_ss_used_<40 characters> | Options, autoload off | One row for each token that has been claimed: the name ends with the first 40 characters of the token's signature, the value is the token's expiry time. Expired rows are pruned. |
wphub_ss_pruned | Transient, 1 hour | Marks that the clean-up of expired rows ran in the last hour. |
_wphub-stop-spam_licence_key, _wphub-stop-spam_key_status | Options | Written by the licence client: the key and active or inactive. |
wpxh_licence_check_v2 | Site transient, 12 hours (1 hour after a failure) | The cached answer of the licence server's update check. |
wpxh_licence_msg_<user id> | Transient, 120 seconds | The message shown once after activating, deactivating or requesting a key. |
WPHUB_STOP_SPAM_VERSION, WPHUB_STOP_SPAM_FILE, WPHUB_STOP_SPAM_DIR | Constants | Defined by the plugin (the directory constant only if it is not already defined). |
DONOTCACHEPAGE | Constant | Defined when a code is made, for page-cache plugins. |
The plugin creates no database tables and no post or user meta. The licence client constant WPXH_LICENCE_SERVER and the filters wpxh_licence_server and wpxh_licence_sslverify change the licence server address and whether its certificate is verified (verification is on unless the server host ends in .local, .test or .localhost, or is localhost or 127.0.0.1). The first WpExperts Hub plugin to load defines the class WPXH_Licence_Client_V2, and every plugin registers itself with it.
File layout: wphub-stop-spam.php (all hooks and the check), classes/ (the settings screen and the image drawing), templates/, assets/fonts/roboto-black.ttf (the font), languages/wphub-stop-spam.pot (a translation template, text domain wphub-stop-spam), licence/, uninstall.php.
8. Privacy and data
The plugin sets no cookies, stores no IP addresses, names or other personal data, and sends no visitor data to any service. It does not register a privacy policy text, a personal data exporter or an eraser.
What is stored
- Your settings in the option
wphub_stop_spam_settings. - A short anonymous marker for each submitted code: an options-table row named
wphub_ss_used_plus a fragment of the signed token, with the token's expiry time as its value. It exists so a code can only be used once. It holds nothing about the visitor. Expired markers are cleaned up (at most once an hour, when a code is submitted) and all remaining markers are removed when the plugin is deleted. - A transient
wphub_ss_prunedthat records that the clean-up has run. - Nothing about the code itself. The answer is worked out from the signed token each time and is not stored.
- Licence data (if you activate a licence): the key and its status in the options
_wphub-stop-spam_licence_keyand_wphub-stop-spam_key_status, and the cache of the update check.
Each protected page contains a hidden token field and an inline PNG image. The plugin loads no script and makes no request from a visitor's browser to any other address.
What is sent anywhere
The image is drawn on your own server and checked there. The plugin makes requests to wpexpertshub.com for the licence only.
| To | When | What is sent |
|---|---|---|
https://wpexpertshub.com/wp-json/wphub-licence/v1/activate | When you click Activate licence | The plugin slug (wphub-stop-spam), the licence key and your site address (site_url()). |
…/deactivate | When you click Deactivate licence | The plugin slug, the licence key and your site address. |
…/send-key | When you click Send licence key | The plugin slug and the email address you typed. |
…/check | When WordPress refreshes its plugin update information in wp-admin or in a WP-Cron run, or when plugin details are opened, and the cached answer is missing or older than 12 hours (1 hour after a failed request) | Your site address and, for each WpExperts Hub plugin active on the site, its slug, its licence key (empty if none) and its installed version. The reply carries the latest version and the package link. The client's own header comment says the package link is only returned for sites with an active licence; that is decided on wpexpertshub.com. |
As with every request WordPress makes, the HTTP headers include WordPress's user agent, which contains the WordPress version and your site address. No customer, order or visitor data is sent. Front-end page requests do not load the licence client. A request to wpexpertshub.com can still happen inside a WP-Cron run, because WordPress checks for plugin updates twice a day through WP-Cron.
Deactivation and uninstall
- Deactivating stops the codes at once and deletes nothing. The plugin has no deactivation hook.
- Deleting the plugin runs
uninstall.php, which deletes the optionwphub_stop_spam_settings, every options row whose name starts withwphub_ss_used_, and the transientwphub_ss_prunedwith its timeout row. On a multisite network it does this for every site. - It leaves behind the licence options
_wphub-stop-spam_licence_keyand_wphub-stop-spam_key_statusand the licence cache, and the key's activation on wpexpertshub.com, unless you deactivated the licence first.
9. Troubleshooting
If you are locked out
This should not happen, but a custom login form that posts to wp-login.php without the code field is refused. Rename the wphub-stop-spam folder in /wp-content/plugins/ over FTP or your file manager, log in, rename it back, and switch that form off under Settings → Protect Login Forms. You can also return false from the filter wphub_stop_spam_enabled for wp_login.
Common problems
| Problem | Likely cause and fix |
|---|---|
| No code image appears on any form, and the settings screen says "The image library (PHP GD with FreeType) is not available on this server..." | PHP GD or FreeType is missing or cannot render the bundled font. Until it is available the plugin does nothing, so your forms keep working. Ask your host to enable GD with FreeType support. |
| Visitors see "The authentication code entered is invalid or has expired. Please try again." | The code was wrong, was typed with the wrong capital or small letter, had already been used (a wrong guess uses it up, and so does a form submitted twice), or the page was open longer than an hour (six hours for comments and reviews). A cached copy of the page also shows an old code. Reload the page for a new code. |
| A code that looks right is refused. | The comparison is case sensitive: the image uses both capital and small letters and the answer must match them exactly. The image never contains I, l, 1, O or 0. A form submitted twice, a page served from a cache, or a changed NONCE_KEY or NONCE_SALT in wp-config.php (the codes are signed with the WordPress nonce salt) also invalidate a code. A filter that changes the code length between showing and checking the code does the same. |
| Logins from a custom or page-builder login form are refused with "Please enter the authentication code shown in the image." | The form posts to wp-login.php (it posts a log field) but does not show the code. Forms made with wp_login_form() get the code automatically. For others, ask the form's developer to call do_action( 'login_form' ) inside the form, or switch the login form off. |
| Guest comments on a theme with its own comment form are refused. | The check does not depend on the code being shown. The theme builds its form without comment_form(). Switch the comment (and review) switch off, or have the theme show the code with get_field_data() (see The PHP interface). |
| A visitor filled in the comment form but forgot a required field, and the code then fails. | The code is checked before WordPress validates the name, email and comment, so the first attempt used it up. The visitor reloads the page for a new image and submits again. The error page tells them to copy their comment first. |
| Guest comments through the REST API or XML-RPC fail. | This is intended while the comment or review switch is on, because those routes cannot show a code. Logged-in users are not affected. |
| The registration form shows no code. | WordPress shows its registration form only when Settings → General → Anyone can register is ticked. Also check that Registration form is ticked in the plugin settings. For WooCommerce, check the My Account registration setting in WooCommerce and the My Account registration form checkbox. |
| There is no code on the checkout page. | The code appears on the classic checkout page's login form only, and WooCommerce shows that form only when Enable log-in during checkout (or account creation During checkout) is ticked under WooCommerce → Settings → Accounts & Privacy. The Cart and Checkout blocks do not use that form, and creating an account during checkout is not a separate form. |
| A page cache or CDN serves a form with an old code. | The plugin defines DONOTCACHEPAGE for plugins that honour it. If your cache ignores it, exclude the login, My Account and comment pages from the cache. |
| My login-limiter plugin counts wrong codes as failed logins. | WordPress 7.1.2 fires wp_login_failed for the captcha_error error, as it does for any failed sign-in other than an empty user name or password. That is how limiters see it. |
| Bots still get through. | No image captcha is unbreakable. Use strong passwords and a login-attempt limiter, and a spam filter for comments. Trackbacks and pingbacks have no form and are not covered. |
| The WooCommerce settings say "WooCommerce is not active; these settings apply as soon as it is." | WooCommerce is not active. The checkboxes are still saved. |
| Update notice says "Automatic update is unavailable for this plugin." | No active licence on this site. Activate it under Plugins → WpExperts Hub Licences, or upload the new zip by hand. |
10. FAQ
Does it use Google reCAPTCHA or another outside service?
No. The image is drawn on your own server and checked there. There is no account, API key, tracking or external request for visitors.
Which forms does it protect?
The WordPress login, registration, lost-password and guest comment forms, and the WooCommerce My Account and classic checkout login form, My Account registration and lost-password forms, and guest product review form. Each has its own switch under Settings → Protect Login Forms.
Can a bot replay a solved code?
No. Each code works once, right or wrong, even when many requests carrying it arrive at the same moment. It is also signed, tied to one form and expires.
Is the code case sensitive?
Yes. The code has to match the image exactly, capital letters and small letters included. The image never contains confusing characters (I, l, 1, O, 0) or letters whose capital and small forms look alike (c, k, o, p, s, u, v, w, x, z), so what is shown is unambiguous. Spaces around the code are ignored, and nothing is changed to upper or lower case. A hint under the box says that case matters.
Does it set cookies or store personal data?
No cookies, and no IP addresses, names or other personal data. To make a code single-use it keeps a short anonymous marker in the options table until the code expires, then removes it.
The code image does not show up. What is wrong?
Your server needs PHP GD with FreeType support. Open Settings → Protect Login Forms: a notice tells you when it is missing. Until it is available the plugin does nothing, so your forms keep working.
Visitors see "invalid or has expired". What should they do?
Reload the page and try again. A code works once and lasts an hour (six hours for comments and reviews), so an old page, a cached page or a form submitted twice needs a fresh code. A wrong guess also uses the code up.
Will it work with page caching?
Pages that show a code define DONOTCACHEPAGE, which page-cache plugins that honour it respect. If a CDN or server cache stores the page anyway, the code in the cached copy expires, so exclude the login, My Account and comment pages from that cache.
Does it work with a theme or page-builder login form?
Forms created with wp_login_form() get the code automatically. A form from a page builder or login plugin that posts to wp-login.php but does not show the field is refused. Switch the login form off, or ask its developer to call do_action( 'login_form' ) inside the form.
Does it work with WooCommerce checkout and the Cart and Checkout blocks?
It protects the login form on the classic checkout page and the My Account forms. Creating an account during checkout is not a separate form and is not covered, and the block checkout does not use the classic login form. The plugin does not read or change orders, products or carts, and declares compatibility with HPOS and the Cart and Checkout blocks.
Are guest comments through the REST API or XML-RPC affected?
Yes. Those cannot show a code, so guest comments through them are refused while the comment or review code is on. Logged-in users are not affected.
Can I change how long a code is valid or how many characters it has?
Yes, with the filters wphub_stop_spam_ttl and wphub_stop_spam_code_length (see Filters).
Does it stop all spam?
No captcha does. It stops automated bots that cannot read the image, but a determined attacker can use OCR, AI or human solving services. For targeted attacks add a login-attempt limiter and comment spam filtering. There is no audio alternative, and trackbacks and pingbacks are not covered.
I am locked out of my site. What do I do?
This should not happen. If a custom login form refuses you, rename the wphub-stop-spam folder in /wp-content/plugins/ over FTP, log in, rename it back, and switch that form off in the settings.
What happens when I delete the plugin?
Its settings, the single-use markers and its housekeeping transient are removed, on a single site or on every site of a network. The licence options stay unless you deactivate your licence first (see Deactivation and uninstall).
How do I activate my licence and get updates?
Open Plugins → WpExperts Hub Licences, paste the key from your purchase email and click Activate licence. Updates then appear on the normal WordPress updates screen. The plugin works without a licence; the key only unlocks update packages. See Licence activation and updates.
Does the plugin contact any outside server?
Only for the licence and updates, and only from wp-admin, WP-Cron and WP-CLI. See What is sent anywhere. The code image is drawn on your own server and visitors' forms never contact wpexpertshub.com.
11. Changelog
1.2 - 2026-10-03
- Security: the verification can no longer be forged (the previous key was public in the source), replayed or retried. Codes are signed, tied to one form, expire and work once, even for many requests sent at the same moment.
- Security: the lost-password check on
wp-login.phpno longer depends on the submit button being posted. - Security: guest comments through the REST API and XML-RPC are refused while the comment or review code is on.
- Fixed: a correct code was refused when another plugin ran the sign-in check early (for example an anti-spam plugin calling
wp_authenticate()): a repeated check within one request now gives the same answer, while a code reused in a later request is still refused. - Changed: the code is now matched exactly, character for character and case for case (
aB4is notAB4). The image shows capital letters, small letters and digits, without look-alike characters. Only spaces and the invisible characters that come along when a code is pasted (non-breaking space, zero-width space) are ignored; nothing is converted to upper or lower case. The input no longer forces capital letters on phones and a short hint says that case matters. - Fixed: a PHP 8.5 deprecation notice (
imagedestroy()) written to the log each time a code image was drawn. - Fixed a fatal error on every login page when the server has no GD/FreeType - the plugin now switches itself off.
- New: settings page (Settings > Protect Login Forms) with a switch for every form.
- New: WordPress registration form, guest comments, WooCommerce product reviews and
wp_login_form()forms. - New: codes avoid look-alike characters; smaller, clearer image.
- New: developer filters
wphub_stop_spam_enabled,wphub_stop_spam_ttl,wphub_stop_spam_code_lengthandwphub_stop_spam_supported. - Improved: escaped output, accessible labels and image description, the plugin's own text domain,
uninstall.phpthat removes all data.
1.0
- Initial release.
12. Support
Email support@wpexpertshub.com. Please include:
- your order ID,
- the plugin version (1.2 or whichever you run), your WordPress version, your PHP version and your WooCommerce version if you use it,
- which form is affected and whether you use a page cache, a CDN or a login-limiter plugin,
- the exact message shown, and
- what you already tried.