Plugin documentation

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

  1. 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 called verification_nonce.
  2. The visitor types the code into a text box under the image.
  3. 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.
  4. 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

ItemRequirementNotes
WordPress6.0 or laterFrom the plugin header (Requires at least). Tested up to 7.1 (the header and readme). This page was checked against WordPress 7.1.2.
PHP7.4 or laterFrom the plugin header (Requires PHP). WordPress itself refuses to activate a plugin on an older PHP version.
PHP GD with FreeTypeRequired for the imageThe 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 extensionOptionalIf 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.
WooCommerceOptionalNeeded 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.
JavaScriptNot usedThe plugin loads no script and no style sheet. The image is part of the page HTML.
Page cachingExclude the form pagesPages 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

  1. Download the plugin zip from your account on wpexpertshub.com.
  2. In WordPress open Plugins → Add New → Upload Plugin, choose the zip and click Install Now. You can also upload the wphub-stop-spam folder to /wp-content/plugins/.
  3. Click Activate. A Settings link and a Licence link appear under the plugin name on the Plugins screen.
  4. Open Settings → Protect Login Forms and untick any form you do not want to protect.
  5. Log out and open your login, registration or comment form to see the code.
  6. 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.

  1. 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_options capability and lists every WpExperts Hub plugin active on the site.
  2. 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.
  3. 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

  1. Activate the plugin. Check that Settings → Protect Login Forms shows no yellow notice about the image library.
  2. Tick the forms that should ask for a code and click Save Changes. All eight are ticked until you change them.
  3. 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.
  4. Type the code exactly as shown, with capital and small letters as drawn, and log in.
  5. If you use a page cache or a CDN, exclude the login, My Account and comment pages from it.
CarefulIf you use a custom login form from a page builder or another login plugin that posts to 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

  1. 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 nonce salt (wp_salt( 'nonce' )). The token is placed in the hidden field verification_nonce.
  2. 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_length allows 4 to 8).
  3. 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.
  4. 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.
  5. 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.
  6. 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).
  7. 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

  1. The plugin reads the token from verification_nonce and the typed code from the field that belongs to the form (see The protected forms).
  2. 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 intl extension 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.
  3. 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.
  4. 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.
  5. 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: aB4 is not AB4.
  6. 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.
  7. 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 then wp_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 appearsWhere it is checkedTyped 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_error and 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 fires wp_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 log field. The WooCommerce login form posts username, not log, so it is handled by its own switch. XML-RPC and REST sign-ins do not post log and 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 with wp_login_form() get the code automatically, because the plugin adds it through login_form_middle. That form posts to wp-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 that comment_form() was given, which can differ from the global post in block themes), then adds the code through comment_form_submit_field. Posts of the type product use 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 inside wp_handle_comment_submission() (used by wp-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_form in 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) at before_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 DONOTCACHEPAGE is 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

MessageWhen
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 failedThe 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)KeyDefaultWhat it does
WordPress: Login formwp_loginOnAsks for the code on the wp-login.php login form and on forms made with wp_login_form().
WordPress: Registration formwp_registerOnAsks for the code on the WordPress registration form.
WordPress: Lost password formwp_lostpasswordOnAsks for the code on the WordPress lost-password form.
WordPress: Comment form (guests only)commentOnAsks 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 formwoocommerce_loginOnAsks for the code on the My Account login form and the classic checkout login form.
WooCommerce: My Account registration formwoocommerce_registerOnAsks for the code on the My Account registration form.
WooCommerce: My Account lost password formwoocommerce_lostpasswordOnAsks for the code on the My Account lost-password form.
WooCommerce: Product review form (guests only)woocommerce_reviewOnAsks 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:

WhatDefaultRangeHow to change it
Code lifetime1 hour (3,600 seconds); 6 hours (21,600 seconds) on comment and review forms60 seconds or moreThe filter wphub_stop_spam_ttl.
Code length5 characters4 to 8The filter wphub_stop_spam_code_length.
A form on or off in codeAs savedtrue or falseThe filter wphub_stop_spam_enabled.
Image library checkDetectedtrue or falseThe 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

FieldSent byMeaning
verification_nonceEvery protected form (hidden)The signed token, expiry.random.signature.
verification_adminLogin, registration and lost-password forms on wp-login.php, and wp_login_form()The code the visitor typed. Its id is the same.
verification_commentComment forms and product review formsThe typed code.
verification_loginWooCommerce login formThe typed code.
verification_registerWooCommerce registration formThe typed code.
verification_lostpasswordWooCommerce lost-password formThe 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:

CallReturnsWhat it does
WpHub_Stop_Spam::forms() (static)arrayThe 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()boolWhether the server can draw the image (the filter wphub_stop_spam_supported applies).
wphub_stop_spam()->is_enabled( string $form )boolWhether 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 nullMakes 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 )stringA fresh token only, or an empty string. It makes its own image that you never see.
wphub_stop_spam()->verification_image( string $form )stringA 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_ErrorChecks 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.
Use get_field_data()Each call to 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.

FilterParametersWhen and what
wphub_stop_spam_enabledbool $enabled, string $formApplied 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_ttlint $seconds, string $formApplied 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_lengthint $lengthDefault 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_supportedbool $supportedThe 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.

HookPriority, argumentsWhat the plugin does
login_form (action)10Prints the code on the wp-login.php login form.
login_form_middle (filter)10Appends the code to the output of wp_login_form().
register_form (action)10Prints the code on the registration form.
lostpassword_form (action)10Prints the code on the lost-password form.
authenticate (filter)99, 3 argumentsWhen 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 argumentsOn a POST to wp-login.php, adds the verification error to the errors object.
lostpassword_errors (filter)10, 2 argumentsOn a POST to wp-login.php, adds the verification error.
comment_id_fields (filter)10, 2 argumentsRemembers which post the comment form is for. The fields are returned unchanged.
comment_form_submit_field (filter)10, 2 argumentsFor visitors who are not logged in, puts the code in front of the submit field.
pre_comment_on_post (action)10Checks 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)999Return 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)10Print the code through the three overridable templates.
woocommerce_process_login_errors (filter)10, 3 argumentsAdds the verification error for the WooCommerce login form.
woocommerce_process_registration_errors (filter)10, 4 argumentsAdds the verification error for the WooCommerce registration form.
lostpassword_post (action)9, 2 argumentsFor 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)10Declares compatibility with custom_order_tables and cart_checkout_blocks, when WooCommerce's features class exists.
init (action)10Registers the licence client (only in wp-admin, cron and WP-CLI).
admin_menu, admin_init, plugin_action_links_<basename>10Add the settings screen, register its option and add the Settings link (admin only).

Templates and markup

FileUsed forOverride
templates/woocommerce-login.phpThe code field on the WooCommerce login formCopy to yourtheme/woocommerce/woocommerce-login.php
templates/woocommerce-register.phpThe code field on the WooCommerce registration formCopy to yourtheme/woocommerce/woocommerce-register.php
templates/woocommerce-lostpassword.phpThe code field on the WooCommerce lost-password formCopy to yourtheme/woocommerce/woocommerce-lostpassword.php
templates/wp-fields.phpThe code field on wp-login.php, wp_login_form() and comment formsNot 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

NameKindContents
wphub_stop_spam_settingsOptionAn array with one entry per form slug, 1 or 0. Written when the settings screen is saved.
wphub_ss_used_<40 characters>Options, autoload offOne 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_prunedTransient, 1 hourMarks that the clean-up of expired rows ran in the last hour.
_wphub-stop-spam_licence_key, _wphub-stop-spam_key_statusOptionsWritten by the licence client: the key and active or inactive.
wpxh_licence_check_v2Site 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 secondsThe message shown once after activating, deactivating or requesting a key.
WPHUB_STOP_SPAM_VERSION, WPHUB_STOP_SPAM_FILE, WPHUB_STOP_SPAM_DIRConstantsDefined by the plugin (the directory constant only if it is not already defined).
DONOTCACHEPAGEConstantDefined 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_pruned that 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_key and _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.

ToWhenWhat is sent
https://wpexpertshub.com/wp-json/wphub-licence/v1/activateWhen you click Activate licenceThe plugin slug (wphub-stop-spam), the licence key and your site address (site_url()).
…/deactivateWhen you click Deactivate licenceThe plugin slug, the licence key and your site address.
…/send-keyWhen you click Send licence keyThe plugin slug and the email address you typed.
…/checkWhen 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 option wphub_stop_spam_settings, every options row whose name starts with wphub_ss_used_, and the transient wphub_ss_pruned with its timeout row. On a multisite network it does this for every site.
  • It leaves behind the licence options _wphub-stop-spam_licence_key and _wphub-stop-spam_key_status and the licence cache, and the key's activation on wpexpertshub.com, unless you deactivated the licence first.
Before deleting the pluginDeactivate your licence under Plugins → WpExperts Hub Licences, then delete the plugin.

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

ProblemLikely 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.php no 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 (aB4 is not AB4). 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_length and wphub_stop_spam_supported.
  • Improved: escaped output, accessible labels and image description, the plugin's own text domain, uninstall.php that 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.