How to add a custom CAPTCHA to login with PHP and JS in WordPress

Contents

Introduction

This tutorial shows, in full detail, how to add a custom CAPTCHA to the WordPress login form using PHP and JavaScript. The solution below is implemented as a small plugin (single-file) that creates an image CAPTCHA (uses GD if available), stores correct answers securely server-side using transients, provides an AJAX refresh button, validates on login server-side, and preserves accessibility and usability. Alternative simple implementations (math/text CAPTCHA) and troubleshooting/security considerations are also included.

Why add a custom CAPTCHA?

  • Reduce automated brute-force login attempts.
  • Keep full control of how the CAPTCHA looks and behaves (no external services).
  • Integrate with WordPress hooks and AJAX to keep the experience smooth for users.

Prerequisites

  • WordPress installation where you can add a plugin or edit your theme functions.php (plugin recommended).
  • PHP 7.x or newer recommended. For image CAPTCHA, GD library (imagecreatetruecolor, imagepng) is required fallback uses plain text or math CAPTCHA if GD is not present.
  • Access to place a single PHP file inside wp-content/plugins/ (or paste code into functions.php for testing, but plugin form is safer).

High-level design

  1. When the login form is shown, inject CAPTCHA markup: an image, hidden key field, input for the user to type the answer, and a refresh button.
  2. When the login page requests a new CAPTCHA, PHP creates a random key and answer and stores the answer in a transient keyed by that key (short TTL, e.g. 5 minutes). The endpoint returns an image URL that includes that key.
  3. The image-generation endpoint reads the key, obtains the stored answer, renders a CAPTCHA image (randomized background, noise, and answer text) and returns PNG content.
  4. Client-side JS allows the user to refresh the CAPTCHA without reloading the page by requesting a new key/image via AJAX and updating the DOM.
  5. On form submission the server verifies the submitted answer against the transient stored value and rejects the login attempt with a WP_Error on mismatch.

Single-file plugin implementation (complete)

Save the file below as wp-content/plugins/custom-login-captcha/custom-login-captcha.php and activate the plugin.

lt?php
/
Plugin Name: Custom Login CAPTCHA
Description: Adds a custom image CAPTCHA to the WordPress login form with AJAX refresh and server-side verification.
Version: 1.0
Author: Your Name
/

if ( ! defined( ABSPATH ) ) {
    exit
}

/
  Settings
 /
define( CLC_CAPTCHA_TTL, 5  MINUTE_IN_SECONDS ) // 5 minutes TTL for answers

/
  Enqueue script and style on login page.
 /
add_action( login_enqueue_scripts, clc_enqueue_login_assets )
function clc_enqueue_login_assets() {
    // Register script
    wp_register_script( clc-captcha-js, plugin_dir_url( __FILE__ ) . captcha.js, array( jquery ), null, true )
    wp_enqueue_script( clc-captcha-js )

    // Provide AJAX URL and nonce
    wp_localize_script( clc-captcha-js, clcCaptcha, array(
        ajaxUrl => admin_url( admin-ajax.php ),
        nonce   => wp_create_nonce( clc-captcha-nonce ),
    ) )

    // Optional styling can be added separately or via inline styles
    wp_register_style( clc-captcha-css, plugin_dir_url( __FILE__ ) . captcha.css )
    wp_enqueue_style( clc-captcha-css )
}

/
  Output CAPTCHA markup in the login form.
 /
add_action( login_form, clc_render_captcha_markup )
function clc_render_captcha_markup() {
    // Create an initial captcha (server-side) and return its key and URL
    new = clc_create_captcha_entry()
    key = esc_attr( new[key] )
    img = esc_url( admin_url( admin-ajax.php?action=clc_get_captchakey= . key . t= . time() ) )
    ?>
    

key, answer => answer ) } / Generate an answer for the captcha. You can choose numeric, alpha, or math-based answers. / function clc_generate_answer() { // Example: random 5-character alphanumeric (avoid confusing chars) chars = ABCDEFGHJKLMNPQRSTUVWXYZ23456789 len = 5 str = for ( i = 0 i lt len i ) { str .= chars[ wp_rand( 0, strlen( chars ) - 1 ) ] } return str } / AJAX: create new captcha (returns JSON with key and image URL). / add_action( wp_ajax_nopriv_clc_new_captcha, clc_ajax_new_captcha ) add_action( wp_ajax_clc_new_captcha, clc_ajax_new_captcha ) function clc_ajax_new_captcha() { check_ajax_referer( clc-captcha-nonce, nonce ) new = clc_create_captcha_entry() key = new[key] img = admin_url( admin-ajax.php?action=clc_get_captchakey= . key . t= . time() ) wp_send_json_success( array( key => key, img => img ) ) } / AJAX endpoint to output the CAPTCHA image for a given key. This outputs PNG image binary directly. / add_action( wp_ajax_nopriv_clc_get_captcha, clc_ajax_get_captcha ) add_action( wp_ajax_clc_get_captcha, clc_ajax_get_captcha ) function clc_ajax_get_captcha() { if ( empty( _GET[key] ) ) { wp_die( Missing key, , 400 ) } key = sanitize_text_field( wp_unslash( _GET[key] ) ) transient_key = clc_captcha_ . key answer = get_transient( transient_key ) if ( answer === false ) { // expired or not found: generate a placeholder image saying Expired clc_output_text_image( EXPIRED, 200, 70 ) exit } // Render image with GD if available otherwise return plain text PNG. clc_output_text_image( answer, 200, 70 ) exit } / Helper: render text string into PNG and output to browser (uses GD) / function clc_output_text_image( text, width = 200, height = 70 ) { if ( function_exists( imagecreatetruecolor ) ) { // Create image im = imagecreatetruecolor( width, height ) // Colors bg = imagecolorallocate( im, wp_rand( 200, 255 ), wp_rand( 200, 255 ), wp_rand( 200, 255 ) ) imagefill( im, 0, 0, bg ) // Add noise: lines, dots for ( i = 0 i lt 6 i ) { lineColor = imagecolorallocate( im, wp_rand( 100, 200 ), wp_rand( 100, 200 ), wp_rand( 100, 200 ) ) imageline( im, wp_rand(0,width), wp_rand(0,height), wp_rand(0,width), wp_rand(0,height), lineColor ) } for ( i = 0 i lt 1000 i ) { dot = imagecolorallocate( im, wp_rand(0,255), wp_rand(0,255), wp_rand(0,255) ) imagesetpixel( im, wp_rand(0,width-1), wp_rand(0,height-1), dot ) } // Text color textColor = imagecolorallocate( im, wp_rand(0,80), wp_rand(0,80), wp_rand(0,80) ) // Use built-in fonts or TTF if available (TTF gives nicer render) fontPath = __DIR__ . /fonts/arial.ttf // optional - include a .ttf file in plugin folder if ( file_exists( fontPath ) ) { // Use TTF fontSize = 24 bbox = imagettfbbox( fontSize, 0, fontPath, text ) textWidth = bbox[2] - bbox[0] textHeight = bbox[1] - bbox[7] x = ( width - textWidth ) / 2 y = ( height textHeight ) / 2 imagettftext( im, fontSize, 0, x wp_rand(-5,5), y wp_rand(-5,5), textColor, fontPath, text ) } else { // Use imagestring to render each char with random offsets font = 5 // built-in font (1..5) x = 10 y = ( height - imagefontheight( font ) ) / 2 for ( i = 0 i lt strlen( text ) i ) { char = text[ i ] imagestring( im, font, x wp_rand(0,4), y wp_rand(-3,3), char, textColor ) x = imagefontwidth( font ) wp_rand(6,12) } } // Output header( Content-Type: image/png ) header( Cache-Control: no-cache, must-revalidate ) imagepng( im ) imagedestroy( im ) } else { // GD not available: output plain PNG that contains text rendered via fallback // Simple fallback: HTTP header and small PNG blob with text not possible here instead return a 1x1 transparent PNG with headers and plain text header( Content-Type: text/plain ) echo text } } / Validate CAPTCHA on login submission. / add_filter( authenticate, clc_validate_captcha_on_authenticate, 30, 3 ) function clc_validate_captcha_on_authenticate( user, username, password ) { // Only validate when login form is submitted (this filter runs at many times) if ( ! isset( _POST[wp-submit] ) ! isset( _POST[log] ) ) { return user } // Allow bypass for some flows if needed: if ( defined( REST_REQUEST ) REST_REQUEST ) { return user } // Collect posted values resp = isset( _POST[clc_captcha_response] ) ? sanitize_text_field( wp_unslash( _POST[clc_captcha_response] ) ) : key = isset( _POST[clc_captcha_key] ) ? sanitize_text_field( wp_unslash( _POST[clc_captcha_key] ) ) : if ( empty( resp ) empty( key ) ) { return new WP_Error( clc_captcha_missing, ltstronggtERRORlt/stronggt: CAPTCHA is required. ) } stored = get_transient( clc_captcha_ . key ) // Remove transient now to prevent reuse (one-time) delete_transient( clc_captcha_ . key ) if ( stored === false ) { return new WP_Error( clc_captcha_expired, ltstronggtERRORlt/stronggt: CAPTCHA expired. Please refresh and try again. ) } // Compare case-insensitive (or adjust to case-sensitive if desired) if ( strcasecmp( resp, stored ) !== 0 ) { return new WP_Error( clc_captcha_invalid, ltstronggtERRORlt/stronggt: CAPTCHA incorrect. ) } // Passed CAPTCHA: return original user so auth continues return user } ?gt

Supporting JS file (captcha.js)

Create captcha.js in the same plugin folder and include the following code. It handles the Refresh button and updates image and hidden key without reloading the page.

(function(){
    (document).ready(function(){
        (#clc-refresh).on(click, function(e){
            e.preventDefault()
            var btn = (this)
            btn.prop(disabled, true).text(Refreshing...)
            .post(clcCaptcha.ajaxUrl, {
                action: clc_new_captcha,
                nonce: clcCaptcha.nonce
            }, function(resp){
                if (resp.success  resp.data) {
                    (#clc-captcha-image).attr(src, resp.data.img)
                    (#clc_captcha_key).val(resp.data.key)
                } else {
                    // fallback: force reload of image url with timestamp
                    var src = (#clc-captcha-image).attr(src).split(?)[0]   ?t=   Date.now()
                    (#clc-captcha-image).attr(src, src)
                }
                btn.prop(disabled, false).text(Refresh CAPTCHA)
            }).fail(function(){
                btn.prop(disabled, false).text(Refresh CAPTCHA)
            })
        })
    })
})(jQuery)

Optional CSS (captcha.css)

Simple styling file placed in the plugin folder. Adjust to taste.

#clc-captcha-image{ border:1px solid #ddd max-width:100% height:auto display:block margin-bottom:6px }
#clc-refresh{ margin-bottom:6px }
#clc_captcha_response{ width:100% box-sizing:border-box }

Alternative simpler approach: Math CAPTCHA (no image)

If you cannot use GD or want a simpler user-facing form, use a math question like What is 7 4? and store the numeric answer server-side as a transient keyed by a random key exactly like the image approach above. The user types the number. The validation code is identical except the displayed markup uses plain text.

// Example generate answer
function clc_generate_math() {
    a = wp_rand(2,9)
    b = wp_rand(1,9)
    return array( question => {a}   {b} = ?, answer => a   b )
}

Accessibility Usability

  • Provide alt text on the image (e.g., alt=CAPTCHA image) and ensure the input has aria-label attributes.
  • Offer a refresh button and make it keyboard-accessible (a real button tag, not only an image link).
  • Provide an alternative for visually impaired users (for example, an audio CAPTCHA or bypass via a challenge question if you authenticate users differently). Audio CAPTCHA is out of scope for this file but can be implemented similarly by generating an audio file on the server and returning a URL via AJAX.

Security considerations

  1. Store correct answers server-side (transient, cache, session) and do not rely on client-side verification only.
  2. Set short TTLs on stored answers (5 minutes is a common choice).
  3. Delete the transient on validation to avoid answer reuse.
  4. Avoid exposing the plain answer in HTML only provide the key to look up the server-side answer.
  5. Limit repeated requests to create new CAPTCHAs for the same IP to reduce resource abuse (rate-limit if needed).
  6. Consider combining CAPTCHA with login rate-limiting and strong password enforcement for better security.

Troubleshooting

  • If images are shown as plain text, your server may not have GD. Check phpinfo() for GD support. If GD is not available, switch to the math/text fallback or install/enable the GD extension.
  • If the CAPTCHA always says expired, ensure PHP can write transients (object cache/backend) and the path/key is correct. Transients rely on the WP options table if no external object cache is configured.
  • If AJAX refresh fails, confirm admin-ajax.php is reachable from the login page and that the JS file is enqueued correctly (open browser console for errors).
  • If you see permission or nonce issues, check that wp_localize_script provided the correct nonce and the AJAX call includes it.

Deployment steps

  1. Create plugin folder (wp-content/plugins/custom-login-captcha).
  2. Place the main PHP file (custom-login-captcha.php), captcha.js, and captcha.css there. Optionally add a fonts folder with a TTF file and update the path in the PHP file.
  3. Activate the plugin from WP Admin gt Plugins.
  4. Navigate to the wp-login.php page to verify the CAPTCHA appears and test refresh and login attempts.

How to disable CAPTCHA in specific scenarios

  • Disable for particular usernames/IPs: modify clc_validate_captcha_on_authenticate to bypass when username matches or IP is whitelisted.
  • Disable for REST or XML-RPC: check defined(REST_REQUEST) or check REQUEST_URI and return early.
  • Add an admin option page to toggle CAPTCHA (not included in this single-file example but straightforward: create a settings page and conditionally short-circuit the render/verification functions).

Extending and customizing

  • Replace the rendering with more sophisticated image distortion and fonts by adding more GD effects and shipping TTF fonts.
  • Implement audio CAPTCHA (generate WAV/MP3 server-side and return URL to audio tag with controls and proper accessibility).
  • Integrate with a logging system to record failed CAPTCHA attempts (useful for security monitoring).
  • Add options page to control TTL, answer length, whether to use image or math CAPTCHA.

References

Final notes

This tutorial provides a complete, production-ready starting point for adding a custom CAPTCHA to WordPress login. You can adapt the generation, storage, and validation patterns to match your security and accessibility requirements. For heavy-traffic sites consider adding rate limits and caching policies for CAPTCHA generation, and optionally an admin settings page to tune behavior. Implement robust logging and monitoring so you can detect abuse patterns and tweak the CAPTCHA difficulty accordingly.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *