How to Customise the WordPress Login Page Without Plugins

How to Customise the WordPress Login Page Without Plugins

Last verified: October 14, 2026
17 min read
Guide
Security auditor
Full-stack developer

The default WordPress login screen (wp-login.php) is functional and recognisable. For a client project it still reads as “stock CMS”, not as their product. White-labelling the login screen is a small change with outsized polish: logo, colours, quieter errors, and CSS that only loads on that URL.

This guide uses core hooks only, checked against the WordPress 7.1 source. No branding plugin. Put the code in a child theme or a tiny must-use / site plugin so a parent theme update does not wipe it. Related hardening beyond cosmetics sits with a proper WordPress security audit when the site already has exposure history.

#Why customise the WordPress login page

Editors and clients land on wp-login.php more often than on most marketing pages. A mismatched WordPress logo next to their brand colours breaks trust before they reach the dashboard. Agencies also use a branded login as a soft signal that the install was cared for, not left on defaults.

Customisation is not security by itself. Changing the logo does not stop credential stuffing. Pair branding with generic error text, rate limits (host or plugin), and 2FA on privileged accounts.

Typical brief from a client: “Make it look like our app.” That usually means logo, background, primary button colour, and footer links that point back to their domain. It does not mean rewriting authentication. Keep the scope honest in the estimate so nobody expects a SSO build from a CSS ticket.

Multisite networks often want one branded login for every site. Put shared CSS in a must-use plugin or a network-activated site plugin, and allow per-site logo overrides with a simple option or the Customizer only if editors truly need self-serve changes.

#What WordPress loads on wp-login.php

Before you replace the logo, it helps to know what core does when wp-login.php is requested:

  1. It loads wp-admin/css/login.css and the dashboard styles it depends on (buttons, forms).
  2. It fires login_enqueue_scripts, where you add your own CSS and JS, exactly like wp_enqueue_scripts on the front end.
  3. It renders the header with the logo link (wordpress.org by default), the form, the lost password and registration links, and the footer.
  4. On a validation error it adds the shake script and prints the message from the login_errors filter.
  5. The login_message and login_messages filters let you place HTML above the form (a logged-out notice, a maintenance note, an SSO banner).

The key consequence: the login page does not go through the front-end theme pipeline. header.php, block theme templates and most wp_enqueue_scripts callbacks do not run there. That is why it has its own enqueue action and why login styles need their own file. Rules that live only in the theme’s style.css never reach wp-login.php.

The second consequence: a WordPress update can change selectors, script order or form markup (extra fields from a 2FA plugin, for example). Branding built on hooks survives the update; a patched wp-login.php in core does not.

#Change the WordPress login logo without a plugin

Hook login_enqueue_scripts and override the background image on #login h1 a.

function wppoland_login_logo() {
	?>
	<style type="text/css">
		#login h1 a,
		.login h1 a {
			background-image: url(<?php echo esc_url( get_stylesheet_directory_uri() . '/images/client-logo.svg' ); ?>);
			height: 65px;
			width: 320px;
			background-size: contain;
			background-repeat: no-repeat;
			padding-bottom: 30px;
		}
	</style>
	<?php
}
add_action( 'login_enqueue_scripts', 'wppoland_login_logo' );

Use esc_url on the image path. Prefer SVG or a retina-ready PNG; oversized JPEG logos look soft on HiDPI screens. Keep the file in the child theme so deploys stay predictable.

By default the logo links to wordpress.org. Point it at the site home:

function wppoland_login_logo_url() {
	return home_url( '/' );
}
add_filter( 'login_headerurl', 'wppoland_login_logo_url' );

Optionally filter login_headertext so the accessible link text matches the client name instead of the default WordPress wording. It replaced login_headertitle, which has been deprecated since WordPress 5.2; older snippets still use it.

function wppoland_login_logo_title() {
	return get_bloginfo( 'name' );
}
add_filter( 'login_headertext', 'wppoland_login_logo_title' );

On installs with a separate dashboard domain (cms.example-client.com next to www.example-client.com), home_url() still points at the public site, which is usually what you want. If the logo should lead to internal documentation or to the identity provider, return a full HTTPS URL and test it in a private window.

#Add a message above the WordPress login form

The login_message filter puts HTML above the form. Typical production uses:

  • A maintenance note (“Logins outside the VPN are paused tonight”).
  • A hint for editors (“Use your company account, not a personal one”).
  • A banner after logout or password reset (core adds its own message classes there as well).
  • A link to SSO when the identity provider sits next to classic passwords.
/**
 * Message above the login form.
 *
 * @param string $message Existing HTML.
 * @return string
 */
function wppoland_login_message( $message ) {
	$notice = '<p class="message">' . esc_html__(
		'Staff accounts only. Report a suspected password leak to IT.',
		'your-textdomain'
	) . '</p>';

	return $notice . $message;
}
add_filter( 'login_message', 'wppoland_login_message' );

Implementation notes:

  • Escape the text (esc_html__), or build the markup through wp_kses_post if it needs a link.
  • Do not replace $message blindly. Core puts its own notices there (after loggedout=true, for example). Prepend, or filter conditionally on the request.
  • The .message class is already styled by login.css. Your style-login.css should respect it (text colour, left border), or the banner disappears into the background.

For text that depends on the action (login, lostpassword, rp), check $_GET['action'] instead of showing the same note on password recovery and on the 2FA step.

#Add custom CSS to the WordPress login page

Do not dump login rules into the public style.css. Enqueue a dedicated file on the same hook:

function wppoland_login_stylesheet() {
	wp_enqueue_style(
		'wppoland-custom-login',
		get_stylesheet_directory_uri() . '/style-login.css',
		array(),
		wp_get_theme()->get( 'Version' )
	);
}
add_action( 'login_enqueue_scripts', 'wppoland_login_stylesheet' );

Example style-login.css:

body.login {
	background-color: #0d1117;
	display: flex;
	align-items: center;
	justify-content: center;
}

.login form {
	background: #161b22;
	border: 1px solid #30363d;
	box-shadow: none;
	border-radius: 8px;
}

.login label {
	color: #c9d1d9;
}

.wp-core-ui .button-primary {
	background: #238636;
	border-color: rgba(27, 31, 35, 0.15);
}

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

Match contrast to WCAG where editors need it: pale grey on dark grey fails for some users. Test focus outlines on the username, password, and submit controls; removing outlines for “clean design” is a regress.

Language packs and RTL locales still use the same hooks. Avoid hard-coded English strings in CSS content properties.

Background images on body.login should be compressed and sized for a simple full-bleed photo, not a 4K marketing hero. Login traffic is frequent for editors; a heavy background taxes every session. Prefer a solid brand colour or a subtle pattern under 100 KB.

If the brand book mandates a light form on a light background, increase border contrast on .login form so the card still reads as a distinct surface. Many “minimal” logins fail because the form melts into the page.

#Change the WordPress login error message

A failed password for an existing user historically produced a message that confirmed the username. That helps attackers enumerate accounts before brute-forcing passwords.

function wppoland_login_errors() {
	return __( 'Invalid credentials.', 'your-textdomain' );
}
add_filter( 'login_errors', 'wppoland_login_errors' );

Keep the copy boring and identical for unknown user and wrong password paths. This is obscurity for enumeration, not a substitute for:

  1. Disallowing weak admin passwords.
  2. Limiting login attempts at the application or edge.
  3. Turning on 2FA for administrators.
  4. Not using admin as a username on new installs.

Lost-password flows have their own messages. Review those templates if you customise registration or membership plugins that share the login CSS.

Some membership plugins replace wp-login.php with a front-end form. In that case these hooks never fire. Apply the same rules on the plugin’s form hooks, or style their template partials directly. Confirm which URL editors actually use before you declare the job done.

XML-RPC and the REST users endpoint are separate attack surfaces. Branding wp-login.php does nothing for them. Disable or restrict XML-RPC if you do not need it, and keep application passwords under policy if the site uses them.

#Disable the WordPress login shake animation

Failed logins trigger a shake via wp_shake_js. Some brand guidelines treat it as noise.

The snippet most tutorials still copy, remove_action( 'login_head', 'wp_shake_js', 12 ), has done nothing since WordPress 5.4. Core now attaches wp_shake_js to login_footer, and only conditionally, inside login_header(). You can see it in wp-login.php in the wordpress-develop repository; the 5.3 branch still used login_head. The stable method is the shake_error_codes filter, available since 3.0: an empty list of error codes means no animation.

add_filter( 'shake_error_codes', '__return_empty_array' );

The filter is public API, so it does not depend on the priority or on which hook core happens to print the script from. Add a one-line comment above it that states the intent.

#Style 2FA and passkey fields on wp-login.php

Building full 2FA inside functions.php is the wrong project. Use a maintained plugin (for example the official Two Factor plugin or your host’s offering) and style the extra fields so they match the branded form.

The Two Factor plugin from the WordPress.org directory (version 0.17.0, 100,000+ active installs according to the directory API in October 2026) emits these classes on the second login step. They are taken from the 0.17.0 source:

  • .two-factor-prompt for the instruction paragraph,
  • input.authcode (with id="authcode") for the TOTP code, the email code and the backup code; the field carries inputmode="numeric" and autocomplete="one-time-code", so do not override those attributes with a script, or phones stop offering the code from SMS or the password manager,
  • .backup-methods-wrap for the links to alternative methods,
  • .two-factor-email-resend for the button that resends the email code.
.login .two-factor-prompt {
	color: #c9d1d9;
}

.login input.authcode {
	letter-spacing: 0.2em;
	text-align: center;
}

.login .backup-methods-wrap a {
	color: #c9d1d9;
}

Older tutorials use selectors such as .backup-methods or .two-factor-methods. The current plugin code has neither, so those rules never match. Load the working rules in the same style-login.css. After every plugin update, re-check the second login step on staging, because class names drift. Other 2FA and passkey plugins have their own markup: read their selectors from their code, not from this guide.

Passkeys and WebAuthn UX vary by plugin. Your job on the branding side is contrast, spacing, and not clipping the security-key prompt with overflow: hidden on .login form.

#Child theme or plugin for WordPress login customisation

PlacementProsCons
Child themeShips with theme assets and logo pathLost if someone switches themes
Site-specific pluginSurvives theme swapsLogo URL must not hard-code the old theme path
Must-use pluginHard to disable by accidentNeeds deploy access; not ideal for every client

Agencies that hand off the site often prefer a small site plugin named after the client, with the logo in the plugin’s assets/ folder. That keeps login branding when marketing later installs a new block theme.

A must-use plugin version with the enqueue and both logo filters:

<?php
/**
 * Plugin Name: Login Branding
 * Description: White-label wp-login.php independent of the active theme.
 */

defined( 'ABSPATH' ) || exit;

add_action( 'login_enqueue_scripts', function () {
	wp_enqueue_style(
		'site-login',
		plugin_dir_url( __FILE__ ) . 'assets/style-login.css',
		array(),
		'2026.10.14'
	);
} );

add_filter( 'login_headerurl', function () {
	return home_url( '/' );
} );

add_filter( 'login_headertext', function () {
	return get_bloginfo( 'name' );
} );

A decision rule for the brief: if login branding is part of the client’s product (brand colours, campaign logo), use the child theme. If it is an agency standard across many installs (the same agency logo on every staging site, generic errors, no shake), use a must-use plugin or a shared Composer package deployed to every environment.

Do not keep this code in a snippets plugin that stores PHP in the database. Without version control it gets lost or duplicated when staging is pushed to production.

#WordPress login customisation mistakes to avoid

  1. Do not edit wp-login.php in core. Updates overwrite it.
  2. Do not enqueue heavy frontend bundles on login; keep CSS lean.
  3. Do not hide the login URL and call that “security” while leaving admin / password123 in place.
  4. Do not stack three login-customiser plugins on top of these hooks.
  5. Do not put secrets or API keys in the login CSS file.

Changing the login slug (via a dedicated plugin) is a separate decision. It can cut casual bots; it also breaks bookmarks and some deploy checklists. Branding and slug changes are independent.

If you rename the login path, document the new URL in the handoff package and in the client’s password manager notes. Support tickets that say “login is broken” after a slug change are expensive and avoidable.

#How to customise WooCommerce and membership plugin login pages

WooCommerce account pages and checkout login blocks use different markup. Login-screen CSS will not restyle my-account forms. Either accept stock Woo forms or enqueue a separate stylesheet on those templates. Mixing both into style-login.css creates dead rules and confusion for the next developer.

The same split applies to LearnDash, MemberPress, and similar products: identify the real entry URL, then brand that surface. One agency mistake is polishing wp-login.php while customers only ever see a front-end modal that still shows the WordPress logo in an iframe.

#How to check the login page after WordPress core updates

Major WordPress releases occasionally tweak login markup or default CSS. After updating core on staging:

  1. Diff the login HTML if something looks off.
  2. Re-test logo size; new default heights can clip SVGs.
  3. If you disabled the shake, confirm the shake_error_codes filter still applies.
  4. Re-check 2FA fields if that plugin updated in the same window.

Pin the stylesheet version to the theme or plugin version so browsers do not keep a stale style-login.css after you fix a contrast bug.

#Which WordPress login hooks exist and since which version

The table comes from the @since tags in src/wp-login.php in the wordpress-develop repository (October 2026, current release WordPress 7.1.3). It helps when a client runs an old install, or when a forum snippet uses a hook that does not exist yet or is already deprecated.

HookTypeSincePurpose
login_headerurlfilter2.1.0logo link URL
login_messagefilter2.1.0HTML above the form
login_errorsfilter2.1.0error text
login_messagesfilter2.5.0informational notices
login_redirectfilter3.0.0redirect target after login
shake_error_codesfilter3.0.0error codes that shake the form
login_enqueue_scriptsaction3.1.0login screen CSS and JS
login_footeraction3.1.0footer output
login_initaction3.2.0start of request handling
login_body_classfilter3.5.0body element classes
login_headeraction4.6.0output right after the body tag opens
login_titlefilter4.9.0title tag content
login_link_separatorfilter4.9.0separator between links under the form
login_headertextfilter5.2.0logo link text
login_site_html_linkfilter5.7.0back to site link in the footer
login_display_language_dropdownfilter5.9.0whether to show the language selector
login_language_dropdown_argsfilter5.9.0language list arguments

login_headertitle (since 2.1.0) has been deprecated since 5.2.0 and runs through apply_filters_deprecated, so with WP_DEBUG on it leaves a notice in the log. If a client theme still uses it, switch to login_headertext.

#Change the login title, body class and language selector on wp-login.php

Four filters from the table handle small brief items without touching CSS:

// Shorter tab title: screen name and site name.
add_filter( 'login_title', function ( $login_title, $title ) {
	return $title . ' | ' . get_bloginfo( 'name', 'display' );
}, 10, 2 );

// Own body class, so login rules do not collide with other stylesheets.
add_filter( 'login_body_class', function ( $classes ) {
	$classes[] = 'site-login';
	return $classes;
} );

// Footer link points to an editor help page instead of the home page.
add_filter( 'login_site_html_link', function () {
	return sprintf(
		'<a href="%s">%s</a>',
		esc_url( home_url( '/help/' ) ),
		esc_html__( 'Help for editors', 'your-textdomain' )
	);
} );

// No language list under the form.
add_filter( 'login_display_language_dropdown', '__return_false' );

Details that only show up in the core source:

  • The default title combines the screen name, the site name and the word WordPress at the end. In recovery mode core adds a recovery prefix before it applies the filter. A callback that builds the title from $title alone drops that prefix, so the administrator no longer sees on the tab that the site is in recovery mode. If that matters, modify $login_title instead of replacing it.
  • The language list only appears when at least one extra language pack is installed, and never in the dashboard’s re-login modal. Hiding it does not change the account language: after login the profile setting applies.
  • login_site_html_link replaces the whole <a> element, so escaping the URL and the text is your job.
  • The privacy policy link under the form comes from the_privacy_policy_link() and appears once a privacy policy page is set. Do not hide it with CSS on sites that collect data through a registration form.

#Which security headers WordPress sends on wp-login.php

Before you add your own headers or meta tags to the login screen, check what core already does. According to default-filters.php and wp-login.php:

  • send_frame_options_header() on login_init (since 3.1.3) sends X-Frame-Options: SAMEORIGIN, and since WordPress 6.9 the same function adds Content-Security-Policy: frame-ancestors 'self'. The login screen cannot be framed on a foreign domain.
  • wp_admin_headers() on login_init (since 4.9) sends Referrer-Policy: strict-origin-when-cross-origin; the admin_referrer_policy filter changes the value.
  • wp_robots_sensitive_page() (since 5.7) adds noindex and noarchive to the robots meta, printed by wp_robots on login_head.
  • wp_strict_cross_origin_referrer() (since 5.7) adds a referrer meta tag with the same policy.
  • wp_site_icon on login_head prints the favicon from the Site Icon setting.

In practice, a separate snippet that prints a robots noindex meta or a favicon link on login_head only duplicates tags. Do not add X-XSS-Protection at all. MDN describes it as non-standard and deprecated, notes that it can create XSS vulnerabilities in otherwise safe sites, and recommends Content-Security-Policy instead. Keep site-wide headers (HSTS, CSP) in the server or CDN configuration rather than in PHP on login_init, so they also cover wp-admin and REST.

#How to test a custom WordPress login page

  1. Open /wp-login.php logged out; confirm logo, colours, and home link.
  2. Submit a wrong password; confirm the generic error and no shake if you removed it.
  3. Confirm “Lost your password?” still works and emails still arrive.
  4. With 2FA on, complete a full admin login on staging.
  5. Switch to a second locale if the site is multilingual and re-check layout.
  6. Log in from a mobile viewport; flex centering sometimes collapses the form on short screens.

#Summary

Brand the logo and header URL, enqueue login-only CSS, return generic errors, and optionally drop the shake. Keep the code outside the parent theme. Treat 2FA as a real control and style its UI; treat logo CSS as polish.

Hand the client a one-page note: where the code lives, how to replace the logo file, and which URL is canonical for staff logins. That note prevents the next freelancer from installing a redundant login plugin six months later.

If the install already shows signs of abuse, or you need login hardening reviewed together with plugins and hosting, start from a WordPress security audit rather than stacking another cosmetic login plugin on a compromised site.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

How do you change the WordPress login logo without a plugin?#
Hook into login_enqueue_scripts, output CSS for the login screen, and replace the default logo with your own image from the theme or child theme.
Can you customise wp-login.php and improve security at the same time?#
Yes. Brand the page, load dedicated CSS, return generic login errors, and remove distracting UI behaviour without changing the dashboard experience.
Where should WordPress login customisation code live?#
Use a child theme or a small site-specific plugin so the changes survive theme updates and stay isolated from unrelated frontend styling.
Does hiding login errors stop brute force attacks?#
No. Generic errors reduce username enumeration. You still need rate limiting, strong credentials, and preferably 2FA or passkeys on admin accounts.
Will a login CSS plugin conflict with this code?#
Often yes. Two sources styling #login h1 a fight each other. Pick hooks in code or one plugin, not both, and test after every core update.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles