The default WordPress login screen is the one part of a nicely designed site that still says WordPress. For a blog nobody cares. For a members area, a client portal or a staff intranet, it looks like you have handed people off to somebody else’s software halfway through.

There are two ways to fix that, and the choice matters more than the CSS. You can restyle wp-login.php with hooks, which keeps every piece of WordPress authentication exactly as it is and only changes how it looks. Or you can build a login form into a normal page template, which gives you complete control of the layout but means you are responsible for redirects, error states and a few edge cases core would have handled.

I use the first route on most sites and the second only when the login genuinely has to sit inside a designed page flow. Below is both, honestly, including where the second one bites. I will also be straightforward about what does and does not make a login page more secure, because there is a lot of nonsense written about that.

Before you start

You need SFTP or SSH access, or a file manager in your hosting panel. The code goes in a small plugin, so you will create one folder under /wp-content/plugins/.

Back up the site before you start, and keep a second browser (or a private window) logged in as an administrator while you work. Login code is the one area where a mistake can lock you out of the thing you need in order to fix the mistake. If that happens anyway, deleting the plugin folder over SFTP restores the default login immediately, so make sure you can reach the files before you begin.

Step 1: Put the code in a plugin, not functions.php

Login behaviour is not a theme concern. If this lives in functions.php, a theme update wipes it, and switching themes silently changes how people log in. Create a plugin instead:

<?php
/**
 * Plugin Name: Site Login
 * Description: Brands the WordPress login screen and controls login redirects.
 * Version:     1.0.0
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

Save it as /wp-content/plugins/site-login/site-login.php, make an assets folder next to it, and activate it. Everything in Step 2 goes in this file. If you would rather this were built and maintained properly for you, it is a small piece of custom plugin work.

Step 2: Brand wp-login.php with hooks (the route I recommend)

WordPress gives you specific hooks on the login screen. Use them and you inherit every fix, every security patch and every accessibility improvement core makes to that page, for free.

Load your stylesheet

add_action( 'login_enqueue_scripts', 'site_login_styles' );

function site_login_styles(): void {
	wp_enqueue_style(
		'site-login',
		plugins_url( 'assets/login.css', __FILE__ ),
		array( 'login' ), // depend on core's login stylesheet so mine loads after it
		'1.0.0'
	);
}

Declaring login as a dependency is the bit people miss. Without it your stylesheet can load before core’s and lose every specificity fight for no obvious reason.

The logo at the top links to wordpress.org by default, with “Powered by WordPress” as its accessible text. Both are filterable:

add_filter( 'login_headerurl', static fn(): string => home_url( '/' ) );
add_filter( 'login_headertext', static fn(): string => get_bloginfo( 'name', 'display' ) );

login_headertext replaced the older login_headertitle filter in WordPress 5.2. If you find login_headertitle in an old snippet, it is deprecated and will throw a notice on modern versions.

Use the site logo rather than hardcoding a path

If the theme already has a custom logo set, reuse it. That way the login screen updates when the logo does:

add_action( 'login_head', 'site_login_logo' );

function site_login_logo(): void {
	$logo_id = get_theme_mod( 'custom_logo' );

	if ( ! $logo_id ) {
		return;
	}

	$src = wp_get_attachment_image_url( $logo_id, 'medium' );

	if ( ! $src ) {
		return;
	}

	// Core sets a fixed 84px square with a background image, so all three
	// properties have to be overridden together or the logo is cropped.
	printf(
		'<style>#login h1 a{background-image:url(%s);background-size:contain;width:100%%;height:64px;}</style>',
		esc_url( $src )
	);
}

Add a message above the form

login_message sits between the logo and the form. Useful for a note to staff, or a link back to a support page:

add_filter( 'login_message', 'site_login_message' );

function site_login_message( string $message ): string {
	// Only on the main login form, not on lost-password or register.
	if ( isset( $_GET['action'] ) ) {
		return $message;
	}

	return $message . '<p class="site-login-note">Staff access only. If you cannot get in, contact the office.</p>';
}

The CSS

This restyles the default screen cleanly without fighting core more than it needs to. Save it as assets/login.css:

body.login {
	background: #0f172a;
	font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

#login {
	width: 340px;
	padding: 6% 0 0;
}

/* Core sets a fixed square for the logo, so set all three properties. */
#login h1 a {
	background-image: url("logo.svg");
	background-size: contain;
	background-position: center center;
	width: 100%;
	height: 64px;
	margin-bottom: 24px;
}

#loginform,
.login form {
	border: 0;
	border-radius: 10px;
	padding: 26px 24px;
	box-shadow: 0 10px 30px rgb(15 23 42 / 35%);
}

.login label {
	color: #334155;
	font-size: 14px;
}

.login input[type="text"],
.login input[type="password"],
.login input[type="email"] {
	border: 1px solid #cbd5e1;
	border-radius: 6px;
	padding: 8px 10px;
	font-size: 16px; /* 16px or more stops iOS zooming in on focus */
}

/* Never remove the focus ring. Replace it with a visible one. */
.login input[type="text"]:focus,
.login input[type="password"]:focus,
.login input[type="email"]:focus {
	border-color: #2563eb;
	box-shadow: 0 0 0 3px rgb(37 99 235 / 30%);
	outline: 2px solid transparent; /* stays visible in Windows high contrast mode */
	outline-offset: 2px;
}

.wp-core-ui .button-primary {
	background: #2563eb;
	border-color: #1d4ed8;
	border-radius: 6px;
	text-shadow: none;
	box-shadow: none;
}

.wp-core-ui .button-primary:hover,
.wp-core-ui .button-primary:focus {
	background: #1d4ed8;
	border-color: #1e40af;
}

/* Links sit on the dark background, so they need their own colour. */
.login #nav a,
.login #backtoblog a,
.login .privacy-policy-page-link a {
	color: #cbd5e1;
}

.login #nav a:hover,
.login #backtoblog a:hover {
	color: #ffffff;
}

.site-login-note {
	color: #94a3b8;
	font-size: 13px;
	text-align: center;
}

Check the contrast of any colours you swap in. The login page is a form, and a form with pale grey labels on white is a form some people cannot read.

Step 3: Or build a fully custom front-end login page

If the login has to live inside a designed page, use wp_login_form(). It outputs a real WordPress login form that posts to wp-login.php, so authentication itself is still core’s. Do not write your own password checking. There is no version of that which ends well.

Create a page template in your theme (or child theme):

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

// A logged-in user has no business on the login page.
if ( is_user_logged_in() ) {
	wp_safe_redirect( home_url( '/account/' ) );
	exit;
}

get_header();
?>

<main class="login-page">

	<?php if ( isset( $_GET['login'] ) && 'failed' === $_GET['login'] ) : ?>
		<p class="login-error" role="alert">
			Those details did not match an account. Check them and try again.
		</p>
	<?php endif; ?>

	<?php
	wp_login_form(
		array(
			'redirect'       => home_url( '/account/' ),
			'label_username' => 'Username or email address',
			'label_log_in'   => 'Sign in',
			'remember'       => true,
			'form_id'        => 'site-loginform',
		)
	);
	?>

	<p class="login-lost">
		<a href="<?php echo esc_url( wp_lostpassword_url( home_url( '/login/' ) ) ); ?>">
			Forgotten your password?
		</a>
	</p>

</main>

<?php
get_footer();

Create a page called Login, assign the template, and it renders. wp_login_form() outputs the hidden redirect field for you, and core does the authenticating.

Catching failed logins

Here is the catch with the front-end route. When a login fails, wp-login.php renders its own error page, and your visitor is thrown back to the screen you were trying to avoid. You have to bounce them back yourself:

add_action( 'wp_login_failed', 'site_login_failed', 10, 2 );

function site_login_failed( string $username, $error ): void {
	$referrer = wp_get_referer();

	// If the attempt did not come from my login page, leave core alone.
	// This keeps wp-login.php and wp-admin working normally for me.
	if ( ! $referrer || str_contains( $referrer, 'wp-login.php' ) ) {
		return;
	}

	wp_safe_redirect( add_query_arg( 'login', 'failed', home_url( '/login/' ) ) );
	exit;
}

An empty username or password never reaches wp_login_failed, so catch that separately on the authenticate filter. Priority 30 puts it after core’s own username and password check, which runs at 20:

add_filter( 'authenticate', 'site_catch_empty_login', 30, 3 );

function site_catch_empty_login( $user, string $username, string $password ) {
	if ( is_wp_error( $user ) && ( '' === $username || '' === $password ) ) {
		$referrer = wp_get_referer();

		if ( $referrer && ! str_contains( $referrer, 'wp-login.php' ) ) {
			wp_safe_redirect( add_query_arg( 'login', 'empty', home_url( '/login/' ) ) );
			exit;
		}
	}

	return $user; // always return, or you break authentication entirely
}

That last comment is not decoration. A filter that forgets to return its value will make every login on the site fail.

Plenty of things in WordPress generate a login URL: comment forms, protected pages, plugins. Filter login_url so they all send people to your page:

add_filter( 'login_url', 'site_login_url', 10, 3 );

function site_login_url( string $login_url, string $redirect, bool $force_reauth ): string {
	$url = home_url( '/login/' );

	if ( $redirect ) {
		$url = add_query_arg( 'redirect_to', rawurlencode( $redirect ), $url );
	}

	if ( $force_reauth ) {
		$url = add_query_arg( 'reauth', '1', $url );
	}

	return $url;
}

Step 4: Control where people land after logging in and out

By default everyone lands in wp-admin. For a customer or member account that is the wrong place, and often a confusing one.

add_filter( 'login_redirect', 'site_login_redirect', 10, 3 );

function site_login_redirect( string $redirect_to, string $requested_redirect_to, $user ) {
	// On a failed login, $user is a WP_Error. Do not touch it.
	if ( ! $user instanceof WP_User ) {
		return $redirect_to;
	}

	// Anyone who can write stays in the admin.
	if ( user_can( $user, 'edit_posts' ) ) {
		return $redirect_to;
	}

	// Everyone else goes to the front-end account area.
	return home_url( '/account/' );
}

add_filter( 'logout_redirect', 'site_logout_redirect', 10, 3 );

function site_logout_redirect( string $redirect_to, string $requested_redirect_to, $user ): string {
	return home_url( '/login/?logged_out=1' );
}

Honouring $requested_redirect_to matters. If someone clicked a protected link and was sent to log in, they expect to arrive at that link afterwards, not at a generic dashboard. The code above preserves that for editors by returning $redirect_to untouched.

Step 5: Stop the error messages leaking usernames

By default WordPress tells you which half you got wrong. “Unknown username” against “The password you entered for the username admin is incorrect” is a username oracle: anyone can work through a list and find out which accounts exist before they start guessing passwords.

add_filter( 'login_errors', 'site_generic_login_error' );

function site_generic_login_error( $error ): string {
	return 'Those details are not right. Check them and try again, or reset your password.';
}

Two honest caveats. First, this flattens every login error, including genuinely useful ones such as an account that is pending approval, so support calls may get slightly harder. Second, it does not close the hole on its own. The lost password form behaves differently for a known and an unknown address, author archives at /?author=1 redirect to a URL containing the username, and the REST users endpoint can list authors. If username privacy actually matters on your site, deal with all of those, and pick display names that differ from login names.

Security, honestly

Changing the login URL from /wp-login.php to /secret-door/ is obscurity, not security. It reduces log noise from bots hammering the standard path, which is a real if minor benefit. It does not stop a targeted attempt, and it breaks in irritating ways: plugins that link to wp-login.php, password reset emails, mobile apps, and anyone on the team who forgets the new URL.

What actually reduces risk, roughly in order:

  • Rate limiting. Lock out an IP or account after a handful of failed attempts. This is the single biggest win, because credential stuffing depends on volume.
  • Strong, unique passwords. Never reuse. WordPress generates strong ones by default and people paste over them; do not.
  • Two-factor authentication on any account with edit_posts or above.
  • Keeping core, plugins and themes updated, which is where most real compromises start.
  • Not running accounts at administrator level when editor would do.

Do not block wp-login.php outright while using a front-end form, either. wp_login_form() posts to it, so blocking it breaks your own login. If you restrict it, restrict GET requests only and leave POST alone.

Login hardening is ongoing rather than a one-off, which is why it is part of a maintenance plan rather than something you do once and forget.

How to check it worked

Open a private browsing window so you are logged out. Load the login screen and confirm the branding, the logo link going to your homepage, and the message text.

Then test the failures, which is the part people skip. Submit a wrong password and confirm you land back on your styled page with a readable error and not on wp-login.php. Submit an empty form. Log in as an administrator and confirm you land in the admin; log in as a subscriber and confirm you land on the account page. Log out and confirm the logout redirect. Finally, tab through the form with the keyboard and check every field has a visible focus ring.

When it does not work

Redirect loop on the login page

Almost always login_redirect sending people back to the login page, or the logged-in check in the template redirecting to a page that itself requires login. Comment out the redirect filters, confirm the loop stops, then reintroduce them one at a time.

The CSS is not applying

Either the path in plugins_url() is wrong (check the browser network tab for a 404) or core’s stylesheet is winning. Adding array( 'login' ) as the dependency fixes the ordering. If the file loads but looks stale, it is browser cache; bump the version string in wp_enqueue_style().

Everything works logged out but the login page shows a cached error

Full page caching does not belong on a login page. Exclude /login/ from your caching plugin and from any server-level cache, otherwise one visitor’s error message gets served to the next person.

The redirect argument in wp_login_form is ignored

The login_redirect filter runs after it and overrides it. Decide which one owns the behaviour and remove the other.

”Cookies are blocked” or “session expired”

Usually a mismatch between the WordPress Address and Site Address settings, often www against non-www, or an http URL on an https site. Fix the URLs rather than the login code.

Locked out completely

Rename the plugin folder over SFTP. WordPress deactivates a plugin whose files have vanished and the default login returns. If you cannot get in at all, you can reset the password directly in the database.

Common questions

Can I do this without touching code?

Yes, there are plugins that give you a login page builder. They work, and they are a reasonable choice if nobody on the project writes PHP. They also add a dependency to something as important as authentication, which is why I prefer forty lines of hooks I can read.

Does a custom login page work with WooCommerce or membership plugins?

Usually, but those plugins often ship their own account and login pages with their own redirect logic. Two sets of redirect filters fighting each other is the most common cause of loops. Check what is already registered before adding your own.

Should I add a registration form too?

Only if the site genuinely needs open registration. Open registration on a site that does not need it is a steady supply of spam accounts. If it stays off, make sure “Anyone can register” is unticked in Settings then General.

Can I use a logo from the media library rather than a file in the plugin?

Yes, that is what the login_head snippet in Step 2 does. It reads the theme’s custom logo, so marketing can change it without touching code.

Will this survive a WordPress update?

The hooks route will. These are stable, long-standing hooks. The front-end route depends on wp-login.php behaviour that is also stable, but there is more of your own code involved, so it is worth retesting login after major updates.

Where that leaves you

The hooks route gives you a login screen that matches the site while keeping every part of WordPress authentication untouched, which is why it is the one I reach for first. The front-end route gives you full control of the page and, in exchange, hands you responsibility for redirects, errors and caching. Neither route makes the site more secure on its own. That comes from rate limiting, strong passwords and two-factor authentication.

If you build the custom route, test the failure paths as carefully as the success path. A login form that works when everything goes right and dumps people on a raw wp-login.php error the moment they mistype is worse than the default screen you were replacing.

If you would rather have this built once, properly, and tested against the membership or ecommerce plugins already on your site, get in touch and tell me what the login needs to do.