You may want to checkout wp_signon as that function can provide a WP_Error object with the appropriate error messages on login failure.. the only drawback is that you will have to create your own form or replace the action url on demand.
A somehow basic login function can be implemented like this:
if (!function_exists('custom_login_function')) {
/**
* Attempts to login a user via POST request.
*
* @return boolean
*/
function custom_login_function()
{
if (is_user_logged_in()) {
throw new \Exception(__('User is already logged in.'), 303);
}
$fields = array(
'nonce' => 'login_nonce',
'user_login' => 'log',
'user_password' => 'pwd',
'remember' => 'remember_me'
);
if (!wp_verify_nonce($fields['nonce'], $fields['nonce'])) {
throw new \Exception(__('Invalid nonce.'), 401);
}
$credentials = array();
array_walk($fields, function(&$field, $key) use ($credentials) {
if (!empty($_POST[$field])) {
$credentials[$key] = esc_sql($_POST[$field]);
}
});
$login_status = wp_signon($credentials, is_ssl());
if (is_wp_error($login_status)) {
throw new \Exception($login_status->get_error_message(), 401);
}
return true;
}
}
You can then use it like this:
if (isset($_POST['log'], $_POST['pwd'])) {
try {
custom_login_function();
$redirect = !empty($_POST['redirect_to'] ? $_POST['redirect_to'] : home_url());
wp_safe_redirect($redirect);
} catch (\Exception $error) {
$login_error = $error->getMessage();
}
}
$login_error
will then contain the error message thrown by the function or by WordPress in case the login failed.
Please notice that the function above also checks for a nonce. This is not implemented in the WordPress login form by default but you can add it to your custom form with a simple function call:
<?php wp_nonce_field('login_nonce', 'login_nonce'); ?>
That will insert a hidden field with the name “login_nonce”.
Best regards.