Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 14 / 18 40 MIN

Hardening a site

Updates, Least Privilege and Secrets

Automatic updates, the administrator count, application passwords and where a credential should live, treated as one problem: how far a single stolen login reaches.

A site gets compromised through one credential. The attacker did not need a zero-day; they needed a plugin nobody had updated since 2023, or a former contractor's administrator account that was never revoked, or an API key in a public repository. Each of those is a different problem to describe and the same problem to fix: one credential should not be enough to reach everything.

Hardening advice splits these into three unrelated sections. Updates are about patching, users are about permissions, secrets are about storage. In practice they are one length: the distance between a single stolen thing and your database. Automatic updates shorten the window during which a known vulnerability is exploitable. Least privilege means a stolen editor login cannot install a plugin. Secrets management means the leak of a payment key does not hand over the site.

Knowing an update exists is not the same as shipping it

WordPress 7.0.7 checks for updates twice a day. On a fresh install the site options that govern core updates are already enabled for development, minor and major releases:

$ wp eval 'foreach ( array( "auto_update_core_dev", "auto_update_core_minor",
  "auto_update_core_major" ) as $o ) { printf( "%-24s %s\n", $o,
  var_export( get_site_option( $o, "unset" ), true ) ); }'
auto_update_core_dev     'enabled'
auto_update_core_minor   'enabled'
auto_update_core_major   'enabled'

Plugins and themes are the opposite. Neither has an auto_update_plugins nor an auto_update_themes option on a fresh install, and the code path in WP_Automatic_Updater::should_update() only proceeds when the update offer carries an autoupdate flag or when the item has been enabled in that option. Out of the box, core patches itself and your plugins wait for you.

The clean way to change that is the per-item filter, which runs for each pending update and receives the item:

add_filter( 'auto_update_plugin', function ( $update, $item ) {
    $pinned = array( 'woocommerce/woocommerce.php' );
    if ( in_array( $item->plugin, $pinned, true ) ) {
        return false; // updates by hand, on a schedule, with a backup
    }
    return true;
}, 10, 2 );

Pin the two or three plugins your business depends on and let the rest update. The one you should never automate for is the one whose update you have not tested, and that is usually the biggest one.

The three ways to switch it all off

AUTOMATIC_UPDATER_DISABLED stops background updates entirely, including the notification email that would otherwise tell you an update exists. WP_AUTO_UPDATE_CORE narrows it: false stops core updates, 'minor' allows security releases only, true allows everything. DISALLOW_FILE_MODS stops updates as a side effect of making the filesystem read-only to WordPress. Pick the smallest one that gets you what you want.

You cannot patch what you cannot list

An update dashboard is a view, not an inventory. When you take over a site, the first useful thing you can produce is a list of every plugin and theme with its version, whether it is active, and when it was last released. The first two come from the filesystem:

if ( ! function_exists( 'get_plugins' ) ) {
    require_once ABSPATH . 'wp-admin/includes/plugin.php';
}

$active = (array) get_option( 'active_plugins', array() );

foreach ( get_plugins() as $file => $plugin ) {
    printf(
        "%-45s %-10s %s\n",
        $file,
        $plugin['Version'],
        in_array( $file, $active, true ) ? 'active' : 'inactive'
    );
}

The third column, last release date, is not in that data. WordPress gets the version you have from the plugin headers on disk and the version available from the update API, and stops there. To answer "is this abandoned", look up each slug in the plugin directory API, which returns a last_updated field, and compare it against a threshold you chose in advance. Two years without a release is the line I use. It is not the same as "vulnerable"; plenty of small plugins are finished rather than abandoned. It is the point at which you owe the client a sentence in writing about who is going to answer a security report for that code, because the original author has stopped.

Two inventories are worth keeping. The filesystem one, generated from get_plugins() and wp_get_themes(), catches a plugin directory with files that no longer appear on any admin screen. The update one, from wp_get_update_data(), catches what is pending right now:

$data = wp_get_update_data();
// array( 'counts' => array( 'plugins' =>, 'themes' =>, 'wordpress' =>,
//                          'translations' =>, 'total' => ) )

Diff the filesystem inventory against the previous week's copy. A plugin that appeared without a deploy is the single most useful alert you can set on a WordPress site, and it is the detection idea module 6 builds on.

Least privilege starts by counting administrators

Run this before anything else: wp user list --role=administrator. Most sites have more administrators than the owner believes, because every request to install one plugin produces one. An administrator can install arbitrary code, so in practice an administrator is shell access with a nicer interface.

The role ladder is built so that you can avoid that. WordPress's six default roles differ by a small number of capabilities, and the differences are load-bearing. An author holds edit_posts and publish_posts and can only touch their own posts. An editor adds edit_others_posts and manage_categories, which is the boundary most editorial teams actually need. Neither editor nor author holds manage_options, install_plugins or edit_users, and that is the point: neither can create an administrator, and neither can change what the site is.

CapabilityAuthorEditorAdministrator
publish_postsyesyesyes
edit_others_postsnoyesyes
manage_categoriesnoyesyes
edit_usersnonoyes
manage_optionsnonoyes
install_pluginsnonoyes

Two capabilities deserve their own sentences. manage_options is the one that owns the site: settings screens, plugin activation, arbitrary option writes, which is how a plugin's stored configuration becomes a foothold. If you have to choose one capability to guard, guard that one. And the file editor that an administrator gets by default is a code editor in the browser; DISALLOW_FILE_EDIT removes the edit_files, edit_plugins and edit_themes capabilities from every role, including administrators, before an attacker can use them. The lesson 1 lab showed the editor pages returning 403 once it is set.

The awkward part is that a small site often has one person who genuinely needs everything. That person should have an administrator account and a second, ordinary account for daily publishing. Shared logins and one account per role defeat the audit trail you are about to build in lesson 3.

An application password is that user, with that user's capabilities

Application passwords look like a way to give an integration narrow access. They are not. An application password authenticates as the user who created it and carries every capability that user has. Create one on an administrator account for a monitoring script and you have given the monitoring script administrator access to the REST API.

Two limits make them safer than a real password in one specific way. They are accepted only on REST and XML-RPC requests, not on the login form, so an application password cannot be used to obtain an admin cookie. And WordPress disables the feature entirely unless it is on HTTPS, or the environment type is local. On the 7.0.7 lab over plain HTTP, wp_is_application_passwords_available() returned false and nothing appeared in the profile screen.

Use them for what they are: per-integration credentials you can revoke individually without changing the human's password. Create them on the lowest-privilege account that can do the job, name them after the system that uses them, and revoke the pair when the integration is retired. The revocation is the part people skip, and it is the whole benefit.

A secret in the database is a secret in every backup

Keys do not belong in the database. Not because the database is insecure, but because the database is the thing you copy everywhere: to staging, to a developer's laptop, into an export file a plugin can read. A payment key stored in wp_options is present in every dump, every migration, and every staging clone, and it is readable by anything that can run SQL in the same database or dump one table.

Put it in the environment or in a file outside the web root and read it at runtime, as lesson 1 does. Then handle the case that no amount of care prevents: the key leaks anyway. A key pasted into a support ticket, or committed by accident, or exported in a backup that was then published. Rotation is the answer, and the reason it is usually deferred is downtime.

The way to rotate without downtime is to make both values valid at once, for a defined window. Store the new credential next to the old one, deploy code that tries the new value and falls back to the old, tell the provider to accept both, and only then remove the old one.

// wp-config.php, during the rotation window only.
define( 'PAYMENTS_KEY_CURRENT', getenv( 'PAYMENTS_KEY_NEW' ) );
define( 'PAYMENTS_KEY_PREVIOUS', getenv( 'PAYMENTS_KEY_OLD' ) );

// Plugin code: retry a failed call once with the previous key.
function m5_charge( $payload ) {
    $result = m5_call( PAYMENTS_KEY_CURRENT, $payload );
    if ( is_wp_error( $result ) && m5_is_auth_error( $result ) ) {
        $result = m5_call( PAYMENTS_KEY_PREVIOUS, $payload );
    }
    return $result;
}

Log every fallback. If the previous key is still being used after a week, something in your infrastructure is authenticating with a value you forgot you had, and that is worth knowing before you delete it. Then delete it, remove the constant, and confirm the log stays empty for a full billing cycle.

Lab 30 minutes

Work on a copy of a site you control, with a backup you have tested.

Produce an inventory of installed plugins and themes with versions and active state. Mark every plugin whose directory API last_updated value is more than two years old, and write one sentence for each explaining who would answer a security report for it.

Then list every administrator account and every account that has not logged in for 90 days. For each one, write down the role it would need if it were created today.

Finally, find one credential that currently lives in the database or in a plugin file, and write the rotation plan: which values are valid at the same time, what reverts if the new one fails, and how you confirm the old one is unused before deleting it.

Show the solution and the reasoning

For the inventory, the two useful sources are get_plugins() for what is on disk and the plugin directory API for what is maintained. get_plugins() requires wp-admin/includes/plugin.php, which is not loaded on a front-end request, so the script needs that require_once explicitly. Combine it with get_option( 'active_plugins' ) rather than trusting an admin screen, because a plugin can be present and inactive while its files still contain a vulnerability that is reachable through a direct request.

An "abandoned" plugin is a judgement, not a fact, so put the judgement in writing. Two years of silence on a 400-line plugin that formats a phone number is a different conversation from two years on a plugin that processes uploads. The output of this exercise is a decision per plugin: keep and watch, replace, or remove. "Remove" is the correct answer for most inactive plugins, and it is the only one that reduces the attack surface rather than describing it.

For the accounts, the pattern that shows up almost every time is an administrator account for a contractor who left, and an editor account that was created as administrator "to save time". The role each one would need today is usually subscriber or editor. Downgrade rather than delete when you are unsure: a role change is reversible and keeps the authorship history attached to the account.

The rotation plan is the part worth writing down before you need it. The properties that make it safe are that both values work at once, that the code path falls back explicitly rather than failing silently, and that the fallback is logged. A rotation without a log entry at the fallback is indistinguishable from a rotation that silently broke every payment for six hours, and you will find out from a customer rather than from a dashboard.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.