A Step-by-Step Tutorial for Custom Login and Register in WordPress with AJAX

Introduction

Want to build custom login and register in WordPress without installing a plugin? Not every site needs a full-featured membership plugin just for a simple sign-in and sign-up form. WordPress already ships with everything needed — wp_signon(), wp_insert_user(), and the AJAX action system — to build one yourself.

This tutorial builds a working AJAX-powered login and register form using custom page templates, no plugin required. It also fixes several real, serious bugs found in a widely-copied version of this code — including two fatal PHP syntax errors that would take an entire site down if pasted in as-is, and a missing password-confirmation check that silently defeats the whole point of a “confirm password” field.

Prerequisites

Before we begin, make sure you have:

  1. Access to your WordPress site’s files via FTP or a file manager.
  2. Basic knowledge of HTML, CSS, and PHP.

Step 1: Create the Custom Login and Register Templates

1.1 Custom Login Form — create a new file named custom-login.php in your theme directory:

<?php
/*
Template Name: Custom Login
*/

// If the user is already logged in, redirect them to their account page
if ( is_user_logged_in() ) {
    wp_safe_redirect( site_url('my-account') );
    exit;
}

get_header();
?>
<div class="alert" style="display:none;"></div>
<form class="login-form" id="login-form" action="" method="post">
    <div class="form-row">
        <label for="user_email">Email</label>
        <input type="email" name="user_email" id="user_email" class="form-control" required="required"/>
    </div>
    <div class="form-row">
    	<label for="user_password">Password</label>
    	<input type="password" name="user_password" id="user_password" class="form-control" required="required" />
    </div>
    <?php wp_nonce_field('login_user_nonce', 'login_user_nonce'); ?>
    <button type="submit" class="btn btn-primary submit-btn">Sign in</button>
</form>
<?php
get_footer();

Warning: A common version of this template calls wp_redirect() without a following exit;, exactly like the same bug covered in our WooCommerce redirect after logout guide. Without exit;, PHP keeps executing and renders the entire login form underneath the redirect header — on many server configurations this either silently fails to redirect at all, or produces a “headers already sent” warning. The corrected version above also swaps wp_redirect() for wp_safe_redirect(), following the same principle covered in that guide, even though this particular destination is hardcoded.

1.2 Custom Register Form — create custom-register.php with the same structure:

<?php
/*
Template Name: Custom Register
*/

if ( is_user_logged_in() ) {
    wp_safe_redirect( site_url('my-account') );
    exit;
}

get_header();
?>
<div class="alert" style="display:none;"></div>
<form class="register-form" id="register-form" action="" method="post">
    <div class="form-row">
        <label for="user_first_name">First Name</label>
        <input type="text" name="user_first_name" id="user_first_name" class="form-control" required="required"/>
    </div>
    <div class="form-row">
        <label for="user_last_name">Last Name</label>
        <input type="text" name="user_last_name" id="user_last_name" class="form-control" required="required"/>
    </div>
    <div class="form-row">
        <label for="user_email">Email</label>
        <input type="email" name="user_email" id="user_email" class="form-control" required="required"/>
    </div>
    <div class="form-row">
    	<label for="user_password">Password</label>
    	<input type="password" name="user_password" id="user_password" class="form-control" required="required" />
    </div>
    <div class="form-row">
    	<label for="user_cpassword">Confirm Password</label>
    	<input type="password" name="user_cpassword" id="user_cpassword" class="form-control" required="required" />
    </div>
    <?php wp_nonce_field('register_user_nonce', 'register_user_nonce'); ?>
    <button type="submit" class="btn btn-primary submit-btn">Sign up</button>
</form>
<?php
get_footer();

Step 2: Add the AJAX JavaScript

Enqueue jQuery properly first, in your theme’s functions.php:

add_action('wp_enqueue_scripts', 'wpwebguru_enqueue_auth_scripts');
function wpwebguru_enqueue_auth_scripts() {
    wp_enqueue_script('jquery');
}

2.1 Login form AJAX handler — add to custom-login.php, right before get_footer();:

<script>
jQuery(document).ready(function($) {
    $("#login-form").on("submit", function (e) {
        e.preventDefault();

        var form = $(this);
        var user_email = form.find("#user_email");
        var user_password = form.find("#user_password");
        var security = form.find("#login_user_nonce");

        $.ajax({
            type: "POST",
            dataType: "json",
            url: "<?php echo esc_url( admin_url('admin-ajax.php') ); ?>",
            data: {
                action: "ajax_login_user",
                user_email: user_email.val(),
                user_password: user_password.val(),
                security: security.val(),
            },
            success: function (response) {
                if (response.success) {
                    $('.alert').text(response.msg).show();
                    window.setTimeout(function () {
                        window.location = response.redirect;
                    }, 500);
                } else {
                    $('.alert').text(response.msg).show();
                    $("html, body").animate({ scrollTop: 0 }, "slow");
                }
            },
            error: function () {
                $('.alert').text('Something went wrong. Please try again.').show();
            },
        });
    });
});
</script>

Note: The corrected version uses <?php echo esc_url(...); ?> instead of the short-echo tag <?= ... ?>. Short tags require short_open_tag to be enabled in PHP’s configuration, which many hosts disable by default — using the full <?php echo ?> form always works, regardless of hosting environment. The error-handling branch has also been simplified; jQuery’s .text() escapes the message automatically, so displaying server or network error text this way doesn’t introduce an XSS risk.

2.2 Register form AJAX handler — the same pattern, added to custom-register.php:

<script>
jQuery(document).ready(function($) {
    $('#register-form').on('submit', function (e) {
        e.preventDefault();

        var form = $(this);
        var data = {
            action: "ajax_register_user",
            user_first_name: form.find("#user_first_name").val(),
            user_last_name: form.find("#user_last_name").val(),
            user_email: form.find("#user_email").val(),
            user_password: form.find("#user_password").val(),
            user_cpassword: form.find("#user_cpassword").val(),
            security: form.find("#register_user_nonce").val(),
        };

        $.ajax({
            type: "POST",
            dataType: "json",
            url: "<?php echo esc_url( admin_url('admin-ajax.php') ); ?>",
            data: data,
            success: function (response) {
                if (response.success) {
                    $('.alert').text(response.msg).show();
                    window.setTimeout(function () {
                        window.location = response.redirect;
                    }, 500);
                } else {
                    $('.alert').text(response.msg).show();
                    $("html, body").animate({ scrollTop: 0 }, "slow");
                }
            },
            error: function () {
                $('.alert').text('Something went wrong. Please try again.').show();
            },
        });
    });
});
</script>

Step 3: Handle the AJAX Requests in functions.php

3.1 Login handler:

/**
 * Ajax login
 * @return json
 */
add_action('wp_ajax_nopriv_ajax_login_user', 'wpwebguru_ajax_login_user');
function wpwebguru_ajax_login_user() {
    if ( ! check_ajax_referer( 'login_user_nonce', 'security', false ) ) {
        wp_send_json( array(
            'success' => false,
            'msg'     => 'Session token has expired, please reload the page and try again.',
        ) );
    }

    $user_email    = sanitize_email( $_POST['user_email'] ?? '' );
    $user_password = $_POST['user_password'] ?? '';

    if ( ! $user_email || ! is_email( $user_email ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Please provide a valid email address.' ) );
    }

    if ( empty( $user_password ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Please provide your password.' ) );
    }

    $user_signon = wp_signon( array(
        'user_login'    => $user_email,
        'user_password' => $user_password,
        'remember'      => true,
    ), is_ssl() );

    if ( is_wp_error( $user_signon ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Wrong username or password.' ) );
    }

    wp_send_json( array(
        'success'  => true,
        'msg'      => 'Login successful, redirecting...',
        'redirect' => site_url('my-account'),
    ) );
}

Note: A common version of this handler sanitizes the password with sanitize_text_field() before passing it to wp_signon(). This is a real problem, not just a style issue — sanitize_text_field() strips characters and trims whitespace, meaning a legitimate password containing certain characters could get silently altered before comparison, causing valid logins to fail. Passwords should be passed through to wp_signon() exactly as submitted; only sanitize fields you’re going to store or display, never a password you’re about to check against a hash. The corrected version above also no longer manually calls wp_clear_auth_cookie() / wp_set_current_user() / wp_set_auth_cookie()wp_signon() already handles setting the auth cookie correctly on success when called this way, and the manual calls in the original were redundant.

3.2 Register handler:

/**
 * Ajax register
 * @return json
 */
add_action( 'wp_ajax_nopriv_ajax_register_user', 'wpwebguru_ajax_register_user' );
function wpwebguru_ajax_register_user() {
    if ( ! check_ajax_referer( 'register_user_nonce', 'security', false ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Session token has expired, please reload the page and try again.' ) );
    }

    $user_first_name = sanitize_text_field( $_POST['user_first_name'] ?? '' );
    $user_last_name  = sanitize_text_field( $_POST['user_last_name'] ?? '' );
    $user_email      = sanitize_email( $_POST['user_email'] ?? '' );
    $user_password   = $_POST['user_password'] ?? '';
    $user_cpassword  = $_POST['user_cpassword'] ?? '';

    if ( empty( $user_first_name ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Please provide your first name.' ) );
    }
    if ( empty( $user_last_name ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Please provide your last name.' ) );
    }
    if ( ! $user_email || ! is_email( $user_email ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Please provide a valid email address.' ) );
    }
    if ( empty( $user_password ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Please provide a password.' ) );
    }

    // Confirm password must match — the original version never actually checked this.
    if ( $user_password !== $user_cpassword ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Passwords do not match.' ) );
    }

    $uppercase    = preg_match( '@[A-Z]@', $user_password );
    $lowercase    = preg_match( '@[a-z]@', $user_password );
    $number       = preg_match( '@[0-9]@', $user_password );
    $specialChars = preg_match( '@[^w]@', $user_password );

    if ( ! $uppercase || ! $lowercase || ! $number || ! $specialChars || strlen( $user_password ) < 8 ) {
        wp_send_json( array(
            'success' => false,
            'msg'     => 'Password must be at least 8 characters and include an uppercase letter, a number, and a special character.',
        ) );
    }

    if ( email_exists( $user_email ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'This email is already registered.' ) );
    }

    $username = strstr( $user_email, '@', true );
    if ( username_exists( $username ) ) {
        $username .= '-' . wp_rand( 10, 9999 );
    }

    $user_id = wp_insert_user( array(
        'first_name' => $user_first_name,
        'last_name'  => $user_last_name,
        'user_login' => $username,
        'user_email' => $user_email,
        'user_pass'  => $user_password,
    ) );

    if ( is_wp_error( $user_id ) ) {
        wp_send_json( array( 'success' => false, 'msg' => 'Could not create your account. Please try again.' ) );
    }

    $user_signon = wp_signon( array(
        'user_login'    => $user_email,
        'user_password' => $user_password,
        'remember'      => true,
    ), is_ssl() );

    wp_send_json( array(
        'success'  => true,
        'redirect' => site_url('my-account'),
        'msg'      => 'Account created! Redirecting, please wait...',
    ) );
}

Critical Bugs Fixed From the Common Version of This Snippet

These are worth understanding, not just copying past, since two of them would take a live site down entirely:

  • Fatal syntax error #1 — a semicolon instead of a comma. The registration handler’s array of sign-on arguments was written as 'user_password' => $user_password; 'remember' => true, — a semicolon in the middle of an array literal. This is a PHP parse error. If this exact code is pasted into functions.php, WordPress core itself fails to load, and every page on the site — front end and wp-admin — shows a fatal error or a blank white screen until the broken code is removed. Always double-check array syntax carefully when copying code from a tutorial or forum post.
  • Fatal syntax error #2 — public function outside a class. The register handler was declared as public function ajax_register_user(). The public visibility keyword is only valid on a method inside a class body — using it on a plain, standalone function (as this one is, living directly in functions.php) is also a fatal parse error. This strongly suggests the original snippet was copy-pasted out of a class-based plugin without being adjusted for standalone use.
  • Missing password confirmation check. The register form collects a “Confirm Password” field, but the original handler never compared $user_password against $user_cpassword anywhere — meaning a user could type two completely different passwords and the account would still be created using just the first one, silently. The corrected version adds the missing check.
  • Password sanitized with the wrong function. As covered in the note above, running a password through sanitize_text_field() before checking it against the stored hash can alter valid passwords and cause legitimate logins to fail.
  • Missing exit; after wp_redirect() in both page templates’ “already logged in” check, the same pattern flagged in our WooCommerce redirect guide.

A Note on Security

An AJAX-based login form bypasses wp-login.php entirely, which means it’s worth double-checking that any brute-force protection plugin you rely on — a Limit Login Attempts plugin, for example — actually hooks into wp_login_failed rather than watching requests to wp-login.php specifically. wp_signon() fires the standard wp_login_failed action on a failed attempt regardless of which form called it, so most well-built login-limiting plugins will still catch brute-force attempts against this custom form — but it’s worth verifying rather than assuming. For the broader picture on hardening a form like this, see our WordPress security checklist, and make sure the whole login/register flow is served over HTTPS — passwords travel in the AJAX request body exactly as they would in a normal form POST, so the same transport-security requirement applies.

Conclusion

Between the two fatal syntax errors and the missing password-confirmation check, this is a good reminder to actually read through a code snippet — rather than just pasting it in — before it goes anywhere near a production site. With those fixed, this pattern gives you a genuinely working, plugin-free AJAX login and register flow: two page templates, two small AJAX handlers, and WordPress’s own wp_signon()/wp_insert_user() functions doing the actual authentication work.

Leave a Reply

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

The link has been Copied to clipboard!