Your WordPress Is Publishing Its Admin Username in Five Places

Short answer

WordPress will hand your administrator login to anyone who asks, through at
least four endpoints, and then your theme will print it on every page for good
measure. Closing the endpoints is well documented. The fifth one is not, and it
is the one that survived my own hardening pass: the author byline. The fix for
that one is not a plugin and not a rename — it is making sure the name your site
displays is not the name your site logs in with.

Why a username is worth hiding at all

A login is half a credential. Publish it and an attacker stops guessing and
starts targeting: they now run password lists against a name they know exists,
instead of burning attempts on names that do not.

That is the entire value, and it is worth being honest about the size of it.
Hiding the username is a speed bump. It is not a wall, and a site that relies on
it is one leak away from having no defence at all. Treat it as one layer under a
login limiter and two-factor authentication, both of which stop the attack this
one merely inconveniences.

I went looking because I was about to write publicly about running this site and
wanted to know what the site was already saying about itself. The answer was:
more than I expected.

1. The REST users endpoint

curl -s https://example.com/wp-json/wp/v2/users

On a default install this returns every author as JSON, including the slug,
which is derived from the login. No authentication needed.

Unset the routes for logged-out visitors:

add_filter('rest_endpoints', function ($endpoints) {
    if (!is_user_logged_in()) {
        unset($endpoints['/wp/v2/users']);
        unset($endpoints['/wp/v2/users/(?P<id>[\d]+)']);
    }
    return $endpoints;
});

Anonymous requests now get a 404. Authenticated ones are untouched, which
matters if anything of yours talks to the API — mine does, because
the pipeline that publishes this site
runs entirely over REST.

2. The author archive

/author/<your-login>/ renders a page. The URL is the disclosure — you do not
even have to load it.

3. The author query parameter

/?author=1 is the one people forget, because it works even when pretty
permalinks are on and it needs no knowledge of the name. WordPress resolves the
ID and redirects.

Both of these get handled together:

add_action('template_redirect', function () {
    if (is_author() || isset($_GET['author'])) {
        wp_safe_redirect(home_url('/'), 301);
        exit;
    }
}, 1);

Note the priority. That 1 at the end is not decoration, and hooking this at
the default priority is the most common way to write this snippet and have it
silently not work. Core’s redirect_canonical() also runs on template_redirect,
at priority 10, and its job is to rewrite /?author=1 into the pretty
/author/<your-login>/ form. It sends a 301 whose Location header contains the
username. If your redirect runs after that, the leak has already happened — in a
header, where you were not looking.

Verify with the redirect suppressed, so you see the first response rather than
the last:

curl -sI "https://example.com/?author=1" | grep -i '^location:'

You want your home page there. If you see an author URL, your hook is too late.

4. The login form

Submit a username that does not exist and WordPress says so — a different
message from the one it gives for a valid username with a wrong password. That
difference is an oracle: it confirms names one at a time.

The fix is to normalise the messages, but there is a trap:

add_filter('authenticate', function ($user) {
    if (is_wp_error($user)) {
        $leaky = ['invalid_username', 'invalid_email', 'incorrect_password'];
        if (in_array($user->get_error_code(), $leaky, true)) {
            return new WP_Error('auth_failed', 'Invalid credentials.');
        }
    }
    return $user;
}, 30);

Normalise those three error codes only. My first attempt replaced every login
message wholesale, which also swallowed my login limiter’s “attempts remaining”
warning and its lockout notice. The plugin was working perfectly; it had simply
been gagged. I spent a while convinced it had broken.

Do not test this vector by hand if you run a login limiter. Three wrong
attempts is all it takes, and then you are locked out of your own site for
twenty minutes. Ask me how I know. That is a separate post.

The fifth one, which no guide mentions

I closed all four, verified each, and considered the job done. Then I looked at
the page source of my own published post.

<a class="url fn n" href="https://example.com/author/<your-login>/"
   title="View all posts by <your-login>" rel="author">

There it is. Twice — once in the href and once in the title — on every single
post page. The theme’s byline had been printing it the entire time.

This one is worth sitting with, because of why it survived. Every fix above
targets an endpoint: a URL you request, a form you submit, a route you can
unset. I had been thinking in endpoints, so I audited endpoints. The fifth leak
was never an endpoint. It was markup, generated by the theme, sitting in
plain text in a page I had already looked at a dozen times.

Hardening has a shape, and things outside that shape do not get checked.

The fix is not a rename

The obvious reaction is to change the login name. That is a genuinely risky
operation — you are logged out the moment it takes effect, the auth cookie no
longer resolves, and if that is your only administrator account you had better be
certain about the password.

You do not need it. The byline shows your login because of two other fields that
happen to match it:

Field What it controls Safe to change?
user_login What you type to log in Risky — invalidates sessions
display_name The name printed in the byline Yes, from your profile screen
user_nicename The slug in the author URL Yes, it is not used for authentication

Set a display name that is not your login — Users → Profile → Nickname, then
pick it in “Display name publicly as” — and the title attribute stops leaking.
Change user_nicename and the href stops leaking too. Neither touches
authentication, neither logs you out, and neither requires a plugin.

The nickname is a two-click change. The slug is one API call, because WordPress
exposes user_nicename as slug on the users endpoint and will not let you edit
it from the profile screen:

curl -s -X POST "https://example.com/wp-json/wp/v2/users/1" \
  -u "USERNAME:APPLICATION PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{"slug":"your-public-name"}'

I did exactly that on this site while writing this post, and then re-ran every
check in the list below from a logged-out session. All five vectors came back
closed. The login name itself is unchanged — I never touched it, my session
survived, and nothing needed a password.

That is the part I want to leave you with, because I had talked myself into the
risky version first. I had a snapshot taken, a rollback plan written, and a
second administrator account half-created before I noticed that the field
actually leaking the name was not the field I was preparing to change. The
dangerous operation was never required. It was just the one that sounded like the
solution.

On a single-author site there is a simpler option still: turn the byline off
entirely. It is telling your readers something they already know.

While you are in there: your version banner

Same page source, a few lines up:

<meta name="generator" content="WordPress <your-exact-version>" />

That is not a placeholder in the original — it is your precise version number,
patch level included, and it tells anyone scanning exactly which advisories to
check. Removing it is one
line — and it is a good example of a fix that looks finished before it is:

remove_action('wp_head', 'wp_generator');       // the <meta> tag
add_filter('the_generator', '__return_empty_string');  // the feed

Drop only the first line and your HTML is clean while /feed/ keeps announcing
the version in its own <generator> element. That is where I read mine from,
after I had already “removed” it from the head.

None of this stops a determined attacker — version detection has other tells. It
removes you from the easy bucket, which is where the automated traffic lives.

Verify it, do not assume it

Every one of these has a check that takes seconds. Run them from a logged-out
session, or the results will lie to you:

curl -s  -o /dev/null -w '%{http_code}\n' https://example.com/wp-json/wp/v2/users
curl -sI "https://example.com/?author=1" | grep -i '^location:'
curl -s  https://example.com/ | grep -o 'name="generator"[^>]*'
curl -s  https://example.com/feed/ | grep -o '<generator>[^<]*'
curl -s  https://example.com/any-published-post/ | grep -o 'rel="author"[^>]*'

The last one is the one I did not think to run for two days. Add it to your list.

Running the checks logged out, from outside, is the general form — and it is the
discipline the rest of this site is built on. Here is the working style it
belongs to.

FAQ

How do I stop WordPress from exposing usernames?

Close four endpoints and one piece of markup: unset the REST users routes for
logged-out visitors, redirect author archives, catch the author query parameter
before core canonicalises it, normalise login error messages, and make sure your
theme’s byline prints a display name rather than the login. The last one is the
one most guides omit.

Is hiding the WordPress username actually worth doing?

It is worth an hour, not a weekend. A username is half of a credential, and
publishing it turns blind guessing into targeted stuffing. It is a speed bump,
not a wall, so pair it with a login limiter and two-factor authentication rather
than relying on it.

Why does blocking the author query parameter not work?

Because WordPress canonicalises it first. The core redirect_canonical()
function runs on template_redirect at the default priority and rewrites the
author query into the pretty author URL, which contains the username. If your own
redirect is hooked at the same priority it never gets the chance to run.

Wrapping up

Four of these are in every hardening checklist. The fifth is in none of them, and
it was the one still leaking after I finished the checklist and moved on.

The pattern is the same one I keep running into on this site:
the failure that reports success,
the fix that looks complete because the thing you were watching went quiet. Four
endpoints returned 404 and 301 exactly as intended. The audit was correct. It was
just aimed at endpoints, and the leak had never been one.

Go and run that last curl. It is the one nobody tells you about.

An audit that is correct and aimed at the wrong category is one of five ways this
site managed to report success while something was still wrong. The others, and
the habit that catches them,
are collected in their own post.

Primary sources:
REST API: users
· rest_endpoints
· redirect_canonical
· authenticate
· the_generator