Skip to content

Tag: WordPress Admin

  • User Enumeration Protection: Your Site Is Telling Strangers Your Username

    The first time I watched a real user enumeration attempt scroll through Guard Dog’s activity log, what struck me was how boring it looked.

    No failed logins. No lockouts. No errors. Just a patient sequence of requests — ?author=1, then ?author=2, then ?author=3 — from one IP address in a country I have never sent a newsletter to. Nothing broke. Nothing got blocked. The site answered every single one politely.

    That is the part that gets missed, and it is why “User Enumeration Protection” lands so badly as a label in a settings screen. It doesn’t sound like a defense against anything. It sounds like a checkbox from a compliance form.

    Enumeration is a fancy word for making a list

    To enumerate is to count off, one at a time. That’s the whole concept.

    A user enumeration attack is somebody building a list of the real usernames on your site. No password guessing. No exploit. No vulnerability in the strict sense of the word. Somebody asks your site a series of perfectly ordinary questions and writes down the answers.

    It’s reconnaissance, not a break-in. Which is exactly why it’s easy to dismiss, and exactly why it matters.

    A username is half the login

    Every attack on a WordPress account has to answer two questions: who, and what’s the password. Enumeration answers the first one for free.

    Once an attacker has a real username, brute-forcing stops being a guess about two unknowns and becomes a guess about one. Credential stuffing gets easier too — those breach dumps floating around are lists of usernames and passwords, and knowing that jsmith exists on your site tells someone which entries in the dump are worth trying. And it makes phishing sound real. “Hi Adam, there’s a problem with your administrator account” is a much better email when the sender knows both of those things are true.

    It also tells an attacker who is worth the effort. A list of eight usernames where one of them is clearly the administrator is a much better target than a site where nobody knows if there are eight users or eight hundred.

    WordPress isn’t being careless. It’s being helpful.

    Here’s the thing that surprised me when I started closing these off: every place WordPress hands out a username, it does so for a reason somebody asked for.

    Author archives exist so bylines have a page. The REST API users endpoint exists so the block editor can populate an author dropdown. oEmbed includes author fields so that when another site links to your post, the preview can say who wrote it. Login errors distinguish “we’ve never heard of that username” from “wrong password” so that real people can work out what they mistyped.

    None of it is a bug. It’s a set of small, reasonable conveniences that add up, collectively, to a public directory.

    The seven doors

    Guard Dog covers seven of them, and they’re worth knowing by name even if you never touch a setting.

    Author archives. Request ?author=1 and WordPress helpfully resolves the ID to that person’s archive and redirects to /author/their-username/. The username is now in the URL. Count up from 1 and you have the staff list.

    The REST API users endpoint. /wp-json/wp/v2/users returns a JSON list of everyone who has published. No login required, on a default install.

    Login error messages. “Unknown username” and “the password you entered for the user X is incorrect” are two different sentences, and the difference between them is a yes-or-no answer about whether that account exists.

    Password reset. Same shape, different form. A site that replies “there is no user registered with that email address” has just confirmed which addresses are registered.

    XML-RPC. The old remote-publishing interface is still enabled on a lot of sites, and methods like wp.getAuthors and wp.getUsers will simply hand over user lists.

    oEmbed. When another site embeds one of your posts, the response WordPress sends back includes the author’s name and profile URL.

    Registration errors. If you allow public sign-ups, “that username is already taken” turns your registration form into a lookup tool.

    Seven doors, and a site owner who has never encountered the phrase has all seven standing open.

    Protection isn’t a wall. It’s a straight face.

    The instinct is to think protection means blocking things. Turn them off. Return 403s. Slam doors.

    That’s the wrong shape, and done badly it’s worse than doing nothing — because a door that slams is still an answer. If ?author=1 returns “access denied” and ?author=47 returns a normal 404, congratulations: you’ve built a very convenient user detector out of your own security feature.

    Real enumeration protection makes every answer the same answer.

    Ask the REST API for the user list and you get an empty array, which is exactly what a site with nothing to show would return. Ask for ?author=1 and you get a 404, the same 404 as any page that was never there. Fail a login and you get “Invalid username or password,” whether or not that username exists. Ask to reset the password for an address the site has never seen and you get “If this email address is registered, you will receive a password reset link” — which is also what a real user sees, because a real user still gets the email.

    The attacker’s list comes back the same length no matter who they ask about. The technique stops producing information. That’s the win, and if it’s working properly, none of your actual visitors will ever notice it exists.

    Where it gets interesting

    Two details from building this that I think are worth saying out loud.

    The first is that blocking ?author=1 and blocking author archives are not the same decision. A multi-author publication needs /author/jane-doe/ to work — those pages have bylines, links, and SEO value attached to them. So Guard Dog only treats the raw numeric ?author=N form as a probe, and leaves the pretty author URLs alone. Protecting the site shouldn’t mean breaking the thing the site is for.

    The second is a bug I shipped. Login error masking works by matching the error code WordPress returns rather than the text of the message, because message text changes with the site’s language and a text match would only ever work in English. That approach came out of fixing a real problem in 1.9.52, where configuring a custom generic login error message actually switched masking off instead of on — which meant a setting that looked like it was tightening security was quietly telling visitors which usernames were real. It’s fixed, and it’s a good reminder that on this particular feature, the failure mode is silent. Nothing breaks. It just stops working.

    What I’d actually do

    If you’re setting this up for the first time: flip the master switch on and leave every vector enabled, which is how Guard Dog arrives — all seven turn on the moment the feature is active. Administrators bypass all of it by default, so your own block editor and media library keep working.

    Then log out, open a private window, and check the handful of things you care about — your author pages, a link preview, a password reset. Turn off exactly the one vector that broke something, if any did. Multi-author sites sometimes want author archives back. Headless setups need the REST API. Everything else can stay on.

    Leave logging on either way. Guard Dog records every attempt as an enumeration_attempt event whether or not it blocked it, so you can look at your own logs and see whether anyone is actually probing you before you decide how aggressive to be. And leave auto-blocking on — twenty attempts in an hour puts that IP on the blacklist for a day, which handles the scanners without you doing anything.

    It doesn’t save you. It just stops helping them.

    I want to be honest about what this feature is worth.

    User enumeration protection will not stop a determined attacker who has decided to target you specifically. Your name is on your bylines. It’s on LinkedIn. It might be in your theme’s Git history. Someone willing to spend an afternoon will find it.

    What it stops is the industrialized version — the scanner hitting ten thousand WordPress sites tonight, taking whatever’s free, and moving on to the next one. That scanner isn’t going to spend an afternoon on you. It’s going to take the empty array and go.

    That’s the whole calculation. Enumeration protection isn’t the feature that saves you; limiting login attempts and two-factor authentication do the saving. This one just declines to hand over the guest list on the way in.


    Guard Dog’s user enumeration protection is free and covers all seven vectors described here. Full documentation is on guarddog.cc, and the plugin is available on WordPress.org.

  • Guard Dog: Building the WordPress Security Tool I Actually Wanted to Use

    Guard Dog: Building the WordPress Security Tool I Actually Wanted to Use

    There’s a specific kind of clarity that comes from what I call “rage coding.”

    A while back, I started a high-stakes, custom WordPress integration for my employer. The project had unique security needs to keep personally identifiable information as well as financial information safe and secure. I had a specific list of security layers and features I needed that would have taken at least a few plugins and some money check all the boxes and even then – I knew there would inevitably be some custom code I would have to write.

    I’d been using a plugin called AIO Login for years, but mostly just to change the admin URL. When I looked into their “Pro” features to fill the gaps in my project, I hit a wall. I don’t begrudge anyone for wanting to get paid for their software, but the “freemium” shift in the WordPress ecosystem—where foundational tools like Pojo One Click Accessibility or Aryo Activity Log are sold off or locked behind subscriptions—started to wear on me.

    I realized I could either pay a nominal fee for a tool that mostly did what I wanted, or I could build the exact tool I needed.

    I chose the latter. Guard Dog is the result.

    The “Crown Jewel” of My Workflow

    Guard Dog has been in the official WordPress repository for about five months, following a six-month deep-dive in development. It is, without hyperbole, my “crown jewel.”

    I use it every single day. It’s installed on my personal sites (including this one), as well as sites that handle hundreds of thousands of users from a global audience each month. I depend on it to secure sites that handle personal data and actual revenue streams. Because I’m the primary user, I’m obsessive about its stability. If it breaks, any and all of the fallout is on me and that’s not something I take lightly at all.

    Built for Me, Shared with You

    I am a very particular person. I built Guard Dog to meet my “needs” first, and then I started adding “wants”—quality-of-life features that make things easier for both admins and end-users.

    Recently, I pushed a major update: Passkey support. I added this primarily because I wanted to use Passkeys on my own sites. It’s the gold standard for modern, passwordless security, and I didn’t think it was something users should have to pay a premium for.

    What’s currently under the hood:

    • Passkey Support: Secure, biometric, or hardware-key logins.
    • Session Management: See who is logged in and where, with the ability to kill sessions instantly.
    • Limit Login Attempts: Brute-force protection that actually works.
    • Temporary User Access: Grant secure, timed access to devs or support staff without permanent credentials.
    • 2FA & IP Whitelisting: The essentials for any hardened site.

    What’s Next: Reducing Friction

    The next phase of the project is focused on OAuth and Social Login.

    As a content consumer, I’ve come to expect “Login with Google”, “Login with Facebook” or some other platform provider. It’s fast, it’s easy, and it reduces the “password fatigue” we all feel. As an admin, it makes user acquisition much smoother. This is a pure quality-of-life play, and I’m currently mapping out the most secure way to bring that to the plugin.

    Why It’s Free (And Will Stay That Way)

    I’m not a salesperson, and this isn’t a pitch. I’m not looking to make money off this plugin. If you use Guard Dog and it helps you sleep better at night, that’s fantastic. If you don’t, that’s okay too.

    The internet runs on WordPress. Big companies have budgets for custom security, but the individual creator or the small team shouldn’t be penalized or left vulnerable because they don’t have (or didn’t think they would need) a budget for basic security.

    I built this for me, but I’m sharing it because a more secure WordPress ecosystem benefits everyone.


    Where to find it

  • Reset WordPress Password Directly in Database

    My current job has found me in a sea of WordPress sites. Since we don’t allow (or want, at this time) users to register, the PHP mail() function is not enabled along with the security risks that PHP mail(); brings along with it. So what happens when a user needs their password reset? Currently, an admin has to manually set a new password. That’s fine and dandy but what happens if you only have one admin account and you’re locked out?

    The answer to that question is simple – reset the password directly in the database. Before you do that however, the new password needs hashed. You can use a simple MD5 hash to insert into your database which WordPress will run through its own wordpress_generate_password() function to create the proper form or you can use this tool that I built in order to create a ready-to-go WordPress password hash and example SQL query to run against your target database.

    Once you’ve got your hash, go into your WordPress database, and then find the ‘wp_users’ table. Inside of that table is a column called ‘user_pass’ – this is the field to enter your hash into.

    Execute the following SQL command (adjusted for your specific parameters):

    UPDATE wordpress.wp_users t SET t.user_pass = 'your-generated-hash' WHERE t.ID = 1;

    Replace “wordpress” with your database name and make sure you have the correct ID if you have multiple users otherwise, someone else is getting a new password.

    This can also be handy when installing WordPress within MAMP or MAMP Pro. I’ve found that in verisons 5 and 6 of MAMP Pro, when adding WordPress as an extra, the password you enter before MAMP installs WordPress never works. So, I’ve used this method to fix several WordPress installs when done through MAMP.

  • Replace Your Old Site URL with a New Site URL In Your WordPress Database

    I use the following SQL queries regularly to move databases between local, staging and development environments in my current role and have found them to be invaluable. There are plugins that can do this but a simple SQL query is going to be faster and the output from the SQL console will give you clear and concise feedback when executing. The following WordPress database queries are my “Swiss army knife” of tools when migrating databases across environments.

    First, to replace the Site Address (URL) and the WordPress Address (URL), use this query:

    UPDATE wp_options SET option_value = replace(option_value, 'http://www.your-old-domain.com', 'http://www.your-new-domain.com') WHERE option_name = 'home' OR option_name = 'siteurl';

    After that, we need to replace the URL’s contained within your post content, which is stored in the “post_content” column contained in the “wp_posts” table. To do that, use this query:

    UPDATE wp_posts SET post_content = replace(post_content, 'http://www.your-old-domain.com', 'http://www.your-new-domain.com');

    while we’re at it, we’ll also update instance of the old URL in the “guid” column. We’ll do that like this:

    UPDATE wp_posts SET guid = replace(guid, 'https://www.your-old-domain.com', 'http://www.your-new-domain.com');

    Your site will almost certainly work perfectly fine if you don’t update the “guid” column but if you rely on RSS then your feed will malfunction. Each guid is also a valid URL and each URL contained in the “guid” column will redirect you to the corresponding post so while this may not seem like a big deal if nothing is obviously broken, it is a significant piece in the overall health and proper function of your site.

    It’s also important to not overlook the “wp_postmeta” table. This table stores all of the data related to each post in your site. Any hardcoded instances of your old site URL can have a negative affect on the functionality of your site. To update the post meta_value, use this query:

    UPDATE wp_postmeta SET meta_value = replace(meta_value, 'http://www.your-old-domain.com', 'http://www.your-new-domain.com');

    Last, and this only applies if you use or have used the comments feature, is that we need to replace the URL’s in any comments made on your site. Top do that, use this query:

    UPDATE wp_comments SET comment_content = replace(comment_content , 'http://www.your-old-domain.com', 'http://www.your-new-domain.com');

    When the queries run successfully, you should see an output like the following:

    Query OK, 2 rows affected (0.03 sec)
    
    Rows matched: 2 Changed: 0 Warnings: 0

    Depending on how many rows the query will check, this may take some time. Updating the “meta_value” on one of my sites with almost 19,000 posts returned this console output:

    sitedb_wp> UPDATE wp_postmeta SET meta_value = replace(meta_value, 'https://www.my-old-url.com', 'https://www.my-new-url.com')
    [2023-10-02 10:23:42] 231,918 rows affected in 1 s 27 ms

    And likewise, updating the “post_content” column returned this console output:

    sitedb_wp> UPDATE wp_posts SET post_content = replace(post_content, 'https://www.my-old-url.com', 'https://www.my-new-url.com')
    [2023-10-02 10:26:03] 86,156 rows affected in 38 s 573 ms

    Keep in mind that I’m executing these queries on an AWS RDS instance that is size db.t3.medium so take your particular database infrastructure into account when running these commands. I can personally confirm that these queries work on WordPress versions 4.8.x up to 6.3.x. I don’t see this changing in future WordPress versions but who knows. Also, these queries do not take into account any third-party plugins or data that said plugins may be storing in your site’s database.