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:
- It loads
wp-admin/css/login.cssand the dashboard styles it depends on (buttons, forms). - It fires
login_enqueue_scripts, where you add your own CSS and JS, exactly likewp_enqueue_scriptson the front end. - It renders the header with the logo link (wordpress.org by default), the form, the lost password and registration links, and the footer.
- On a validation error it adds the shake script and prints the message from the
login_errorsfilter. - The
login_messageandlogin_messagesfilters 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 throughwp_kses_postif it needs a link. - Do not replace
$messageblindly. Core puts its own notices there (afterloggedout=true, for example). Prepend, or filter conditionally on the request. - The
.messageclass is already styled bylogin.css. Yourstyle-login.cssshould 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:
- Disallowing weak admin passwords.
- Limiting login attempts at the application or edge.
- Turning on 2FA for administrators.
- Not using
adminas 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-promptfor the instruction paragraph,input.authcode(withid="authcode") for the TOTP code, the email code and the backup code; the field carriesinputmode="numeric"andautocomplete="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-wrapfor the links to alternative methods,.two-factor-email-resendfor 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
| Placement | Pros | Cons |
|---|---|---|
| Child theme | Ships with theme assets and logo path | Lost if someone switches themes |
| Site-specific plugin | Survives theme swaps | Logo URL must not hard-code the old theme path |
| Must-use plugin | Hard to disable by accident | Needs 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
- Do not edit
wp-login.phpin core. Updates overwrite it. - Do not enqueue heavy frontend bundles on login; keep CSS lean.
- Do not hide the login URL and call that “security” while leaving
admin/password123in place. - Do not stack three login-customiser plugins on top of these hooks.
- 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:
- Diff the login HTML if something looks off.
- Re-test logo size; new default heights can clip SVGs.
- If you disabled the shake, confirm the
shake_error_codesfilter still applies. - 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.
| Hook | Type | Since | Purpose |
|---|---|---|---|
login_headerurl | filter | 2.1.0 | logo link URL |
login_message | filter | 2.1.0 | HTML above the form |
login_errors | filter | 2.1.0 | error text |
login_messages | filter | 2.5.0 | informational notices |
login_redirect | filter | 3.0.0 | redirect target after login |
shake_error_codes | filter | 3.0.0 | error codes that shake the form |
login_enqueue_scripts | action | 3.1.0 | login screen CSS and JS |
login_footer | action | 3.1.0 | footer output |
login_init | action | 3.2.0 | start of request handling |
login_body_class | filter | 3.5.0 | body element classes |
login_header | action | 4.6.0 | output right after the body tag opens |
login_title | filter | 4.9.0 | title tag content |
login_link_separator | filter | 4.9.0 | separator between links under the form |
login_headertext | filter | 5.2.0 | logo link text |
login_site_html_link | filter | 5.7.0 | back to site link in the footer |
login_display_language_dropdown | filter | 5.9.0 | whether to show the language selector |
login_language_dropdown_args | filter | 5.9.0 | language 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
$titlealone drops that prefix, so the administrator no longer sees on the tab that the site is in recovery mode. If that matters, modify$login_titleinstead 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_linkreplaces 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()onlogin_init(since 3.1.3) sendsX-Frame-Options: SAMEORIGIN, and since WordPress 6.9 the same function addsContent-Security-Policy: frame-ancestors 'self'. The login screen cannot be framed on a foreign domain.wp_admin_headers()onlogin_init(since 4.9) sendsReferrer-Policy: strict-origin-when-cross-origin; theadmin_referrer_policyfilter changes the value.wp_robots_sensitive_page()(since 5.7) addsnoindexandnoarchiveto the robots meta, printed bywp_robotsonlogin_head.wp_strict_cross_origin_referrer()(since 5.7) adds a referrer meta tag with the same policy.wp_site_icononlogin_headprints 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
- Open
/wp-login.phplogged out; confirm logo, colours, and home link. - Submit a wrong password; confirm the generic error and no shake if you removed it.
- Confirm “Lost your password?” still works and emails still arrive.
- With 2FA on, complete a full admin login on staging.
- Switch to a second locale if the site is multilingual and re-check layout.
- 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.







