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:
- Access to your WordPress site’s files via FTP or a file manager.
- 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 intofunctions.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 functionoutside a class. The register handler was declared aspublic function ajax_register_user(). Thepublicvisibility keyword is only valid on a method inside a class body — using it on a plain, standalone function (as this one is, living directly infunctions.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_passwordagainst$user_cpasswordanywhere — 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;afterwp_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.