Every agency hits the same moment: the site is beautiful, the dashboard is clean, and then the client logs in and sees a plain blue-grey screen with the WordPress logo on top. If you want a genuinely white-labeled handover, you need to customize the WordPress login page itself.
Most tutorials answer that with “install a login customizer plugin”. That works, but it also means one more plugin to update, one more vendor to trust, and one more thing that can break on a client site you maintain. This guide takes the other route: a few well-chosen hooks and a single CSS file, deployed the way a professional team would deploy it. The reasoning is set out in this piece.
What you will build
By the end of this tutorial, the wp-login.php screen for your client will have:
- The client logo instead of the WordPress logo, linking to the client’s homepage
- A branded background (image, gradient or solid colour)
- Restyled form fields, labels and submit button matching the brand
- A custom welcome message above the form
- Generic error messages that do not leak whether a username exists
- A role-based redirect after login (editors to Posts, clients to the dashboard, etc.)
- A discreet agency credit in the footer, if you want one
Total: roughly 60 lines of PHP and 70 lines of CSS. No plugin.

Before you start: never edit wp-login.php
This is the single most important rule. The file wp-login.php lives in the WordPress core directory and is overwritten on every core update. Any change you make there disappears silently, usually at the worst possible moment.
WordPress exposes a full set of hooks specifically so you never have to touch it. Use those instead.
Where should the code live?
| Location | Best for | Survives theme switch? |
|---|---|---|
Child theme functions.php |
One-off sites, branding tied to the theme | No |
Custom plugin in /wp-content/plugins/ |
Reusable agency boilerplate | Yes (can be deactivated) |
Must-use plugin in /wp-content/mu-plugins/ |
White-label work the client should not switch off | Yes (cannot be deactivated from admin) |
Our recommendation for agency work: the mu-plugin. The branding stays in place even if the client swaps the theme or a junior dev deactivates something, and it keeps the login logic separate from presentation code.
Create /wp-content/mu-plugins/client-login-branding.php and start with this header:
<?php
/**
* Plugin Name: Client White Label Login
* Description: Rebrands the wp-login.php screen for this client.
* Version: 1.0.0
* Author: Your Agency
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
If you prefer the child theme route, drop everything below into functions.php instead and swap plugin_dir_url( __FILE__ ) for get_stylesheet_directory_uri().
Step 1: Load a dedicated login stylesheet
The hook you need is login_enqueue_scripts. It fires only on the login, registration and lost password screens, so your CSS never loads on the front end or in the admin.
function agency_login_assets() {
$css_path = __DIR__ . '/assets/login.css';
$css_url = plugin_dir_url( __FILE__ ) . 'assets/login.css';
wp_enqueue_style(
'agency-login',
$css_url,
array( 'login' ),
file_exists( $css_path ) ? filemtime( $css_path ) : '1.0.0'
);
}
add_action( 'login_enqueue_scripts', 'agency_login_assets' );
Two details that separate sloppy code from clean code:
array( 'login' )declares a dependency on the core login stylesheet, so yours always loads after it and your rules win without!importantspam.filemtime()gives you automatic cache busting. Edit the CSS, the version string changes, the browser and any CDN pick it up immediately.
Prefer no extra HTTP request?
For very small amounts of CSS, you can print it inline on the same hook:
function agency_login_inline_css() {
$logo = plugin_dir_url( __FILE__ ) . 'assets/client-logo.svg';
?>
<style>
.login h1 a {
background-image: url('<?php echo esc_url( $logo ); ?>');
background-size: contain;
width: 100%;
height: 90px;
}
</style>
<?php
}
add_action( 'login_enqueue_scripts', 'agency_login_inline_css' );
Always run dynamic URLs through esc_url(). It costs nothing and it is the kind of detail a code review will catch.

Step 2: Replace the WordPress logo with the client logo
The logo is an <h1> containing a link, and WordPress renders it as a background image. Target it like this in assets/login.css:
.login h1 a {
background-image: url('../assets/client-logo.svg');
background-size: contain;
background-position: center center;
background-repeat: no-repeat;
width: 100%;
height: 90px;
margin-bottom: 24px;
}
Use an SVG or a 2x PNG. The login logo renders on retina screens and a 300px-wide JPEG will look amateurish on the one screen your client sees every single day.
Fix the logo link and tooltip
By default the logo links to wordpress.org with the title “Powered by WordPress”. That is the fastest way to blow your white-label cover. Two filters solve it:
add_filter( 'login_headerurl', function () {
return home_url( '/' );
} );
add_filter( 'login_headertext', function () {
return get_bloginfo( 'name' );
} );
Note: login_headertext replaced the old login_headertitle filter. If you are copying code from a 2016 tutorial, that is why your tooltip never changed.
Step 3: Brand the background
The login screen body carries the class .login, which makes full-screen backgrounds trivial:
body.login {
background: #0f172a url('../assets/login-bg.jpg') no-repeat center center fixed;
background-size: cover;
}
/* Gradient alternative, no image weight */
body.login {
background: linear-gradient(135deg, #0f172a 0%, #2563eb 100%);
}
Keep the hero image under 250 KB and serve WebP where you can. The login page is not the place to ship a 3 MB photograph.
Add a per-client body class
If you maintain a multisite or a shared boilerplate, the login_body_class filter lets one stylesheet handle many brands:
add_filter( 'login_body_class', function ( $classes ) {
$classes[] = 'brand-acme';
return $classes;
} );
Step 4: Style the form, fields and button
This is where the page stops looking like “WordPress with a logo swapped” and starts looking like a product. Here is a solid base to adapt:
#login {
width: 400px;
padding: 6% 0 0;
}
.login form {
border: 0;
border-radius: 14px;
padding: 32px 28px 26px;
box-shadow: 0 24px 50px rgba(15, 23, 42, .28);
}
.login label {
color: #334155;
font-size: 14px;
font-weight: 600;
}
.login input[type='text'],
.login input[type='password'],
.login input[type='email'] {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 10px 12px;
font-size: 15px;
box-shadow: none;
}
.login input[type='text']:focus,
.login input[type='password']:focus,
.login input[type='email']:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .25);
outline: 2px solid transparent;
}
.wp-core-ui .button-primary {
background: #2563eb;
border-color: #2563eb;
border-radius: 8px;
height: 44px;
font-size: 15px;
text-shadow: none;
box-shadow: none;
}
.wp-core-ui .button-primary:hover,
.wp-core-ui .button-primary:focus {
background: #1d4ed8;
border-color: #1d4ed8;
}
.login #nav a,
.login #backtoblog a {
color: #e2e8f0;
}
.login #nav a:hover,
.login #backtoblog a:hover {
color: #ffffff;
}
.login .message,
.login .notice,
.login #login_error {
border-radius: 8px;
border-left-width: 4px;
}
Do not break the focus outline
A surprising number of “custom login” snippets set outline: none on inputs. That removes the only visual cue keyboard users have. Replace it with a visible focus ring, as above, and keep your contrast ratio at 4.5:1 minimum for label text. Accessibility is part of the deliverable, not a bonus.
Respect dark mode
@media (prefers-color-scheme: dark) {
.login form {
background: #111827;
}
.login label,
.login form .input {
color: #e5e7eb;
}
}

Step 5: Add a custom message above the form
A short line of context turns a generic screen into a managed service touchpoint:
add_filter( 'login_message', function ( $message ) {
if ( ! empty( $message ) ) {
return $message; // Keep core notices intact.
}
return '<p class="message agency-welcome">Welcome back. Need help? Email <a href="mailto:[email protected]">[email protected]</a>.</p>';
} );
The early return matters: without it you overwrite legitimate messages such as “Check your email for the confirmation link”.
Step 6: Harden the error messages
By default WordPress tells an attacker whether a username exists (“Unknown username” vs “The password you entered is incorrect”). That is free reconnaissance. Replace both with one neutral message:
add_filter( 'login_errors', function () {
return 'Those credentials do not match our records. Please try again.';
} );
Pair it with a hidden password-reset hint and you have removed the easiest enumeration vector on the site. This single filter is why we consider the code-based approach better than most plugins: you control exactly what is exposed.

Step 7: Redirect users to the right place after login
Clients rarely want the dashboard. Editors usually want the posts list. Shop managers want orders. Use login_redirect:
add_filter( 'login_redirect', function ( $redirect_to, $requested, $user ) {
if ( ! $user instanceof WP_User ) {
return $redirect_to;
}
if ( in_array( 'editor', (array) $user->roles, true ) ) {
return admin_url( 'edit.php' );
}
if ( in_array( 'author', (array) $user->roles, true ) ) {
return admin_url( 'edit.php?post_type=page' );
}
return $redirect_to;
}, 10, 3 );
Always respect $requested when it is set, so deep links from emails still work. The guard clause above does exactly that.
Step 8: Finish the white label
Two small touches complete the effect.
Footer credit
add_action( 'login_footer', function () {
echo '<p class="agency-credit" style="text-align:center;color:#94a3b8;font-size:12px;">Maintained by Your Agency</p>';
} );
Hide the language switcher
On single-language client sites the dropdown is just noise:
add_filter( 'login_display_language_dropdown', '__return_false' );
Hook reference cheat sheet
| Hook | Type | What it controls |
|---|---|---|
login_enqueue_scripts |
Action | Load CSS and JS on login screens only |
login_headerurl |
Filter | Destination of the logo link |
login_headertext |
Filter | Logo link text and tooltip |
login_body_class |
Filter | Extra CSS classes on the body tag |
login_message |
Filter | Message block above the form |
login_form |
Action | Inject markup inside the form |
login_errors |
Filter | Error text shown on failed login |
login_footer |
Action | Markup at the bottom of the page |
login_redirect |
Filter | Destination after a successful login |
logout_redirect |
Filter | Destination after logout |

QA checklist before you hand it to the client
- Open
/wp-login.phpin a private window (a logged-in session redirects you straight to the dashboard). - Check
/wp-login.php?action=lostpasswordand the reset-password screen. They use the same CSS and are the ones people forget. - Submit a wrong password and confirm the generic error renders correctly inside your styled box.
- Test at 360px width. The default
#loginwidth of 400px can overflow on small phones, so add a media query if needed. - Tab through the form with the keyboard and verify the focus ring is clearly visible.
- Confirm the logo loads over HTTPS with no mixed-content warning.
- Switch the theme on staging. If the branding disappears, your code is in the theme and should move to an mu-plugin.
- Run one core update on staging and re-check. Nothing should change, which is exactly the point of using hooks.
Plugin vs code: an honest comparison
| Criteria | Custom code | Login customizer plugin |
|---|---|---|
| Setup time (first site) | 30 to 45 minutes | 10 minutes |
| Setup time (next 10 sites) | About 5 minutes, drop in the mu-plugin | 10 minutes each, manual re-config |
| Front-end weight | One small CSS file, login only | Admin assets, options, sometimes upsell notices |
| Update and security surface | Yours alone | Third-party dependency |
| Client can edit it | No (a feature for white-label work) | Yes |
| Best fit | Agencies, maintenance plans, multisite | Single site owners with no dev access |
Frequently asked questions
How can I customize my WordPress login screen?
Add a function to an mu-plugin or your child theme’s functions.php that hooks into login_enqueue_scripts and enqueues a custom stylesheet. Then use that CSS to restyle body.login, .login h1 a, .login form and the input fields. Pair it with the login_headerurl and login_headertext filters so the logo points at the client site instead of wordpress.org.
How do I change the logo on my WordPress login page?
Set a background-image on the .login h1 a selector pointing to your logo file, with background-size: contain and an explicit width and height. Use SVG when possible so it stays sharp on high-density displays. Anyone digging further should read Custom Login Page Customizer.
How do I change the login page URL in WordPress?
WordPress has no core setting for this, and the hooks above do not move the page. You can route traffic through a friendlier slug with a rewrite rule or a server-level redirect to wp-login.php, but be aware that renaming the login URL is obscurity, not security. Rate limiting, strong passwords and two-factor authentication protect the login far more effectively.
Can I use a page I designed in my theme as the login page?
Yes. Build a front-end template and render the form with wp_login_form(), then point failed attempts back to it. It gives you total design freedom, but you take on responsibility for lost-password flows, registration, nonces and error handling. For most agency projects, restyling wp-login.php delivers 95 percent of the value for 10 percent of the effort.
Will customizing the login page slow down my site?
No. Everything here loads only on login-related screens, so your front-end pages and Core Web Vitals are untouched. Keep the background image optimized and the total payload stays in the tens of kilobytes.
Will a WordPress core update wipe my changes?
Not if you follow this guide. Core updates replace wp-login.php but never touch mu-plugins, your child theme or your custom CSS file. That is precisely why the hook-based approach is the one to use on sites you maintain.
Wrapping up
A branded login screen is one of those small deliverables with an outsized effect: it is the first thing a client sees every time they come to work on their site. Doing it with hooks instead of a plugin keeps the stack lean, keeps the security surface small, and gives you a reusable asset you can drop onto the next project in minutes. A fuller account is out there.
Package the mu-plugin, swap the logo, change two hex codes, and you have a white-labeled login ready to ship.
