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.
| Capability | Author | Editor | Administrator |
|---|---|---|---|
publish_posts | yes | yes | yes |
edit_others_posts | no | yes | yes |
manage_categories | no | yes | yes |
edit_users | no | no | yes |
manage_options | no | no | yes |
install_plugins | no | no | yes |
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.
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.