
WordPress security is not one plugin, one code snippet, or one hosting feature. It is a set of layers that reduce the chance of a mistake becoming an incident and make recovery possible when something still goes wrong.
This is the checklist I use when building or maintaining WordPress applications.
TL;DR
- Run supported software and apply security updates promptly.
- Treat every plugin, theme, and runtime component as a dependency you must maintain.
- Validate input, sanitize only when transformation is intended, and escape at output.
- Check capabilities separately from nonces.
- Prefer WordPress core APIs; prepare any custom SQL that remains necessary.
- Protect authentication at the infrastructure layer and require 2FA for privileged users.
- Use HTTPS across the entire site.
- Keep database and filesystem backups, and test that they can actually be restored.
1. Maintain core and every dependency
Run the latest supported WordPress release and apply security releases promptly. Keep themes and plugins updated, remove extensions you no longer use, and do not disable automatic maintenance updates unless you have a monitored process that replaces them.
There is no universal “update every Tuesday” schedule that fits every site. Define a policy based on the site’s risk and operational needs:
- Monitor core, plugin, theme, PHP, database, and hosting security notices.
- Back up before significant changes.
- Test important upgrades in staging when practical.
- Verify critical user flows after deployment.
- Have a rollback or recovery procedure that includes database changes.
Automatic rollback can help with some installation failures and fatal errors, but it is not a substitute for a backup. It may not undo database migrations or functional regressions.
Treat plugins and themes as runtime dependencies
Use WordPress.org or reputable vendors as a starting point, not as a guarantee. Review maintenance activity, compatibility, ownership changes, closure notices, bundled libraries, and how updates are delivered.
Page builders and custom-field tools can be productive, but each one adds code, patching responsibility, and possible runtime coupling. Prefer native WordPress APIs and blocks when they meet the requirement. If a project benefits from Advanced Custom Fields, use it deliberately and maintain it like any other dependency—not as an automatic security recommendation.
For an example of reducing that coupling, see Removing ACF as a Runtime Dependency from WordPress.
2. Limit access and strengthen authentication
Use long, unique credentials generated by a password manager for WordPress, hosting, the domain registrar, CDN, database, and administrative email accounts.
Require two-factor authentication for administrators and other privileged users. Self-hosted WordPress does not currently provide interactive-login 2FA in core, so use a maintained plugin or an identity provider integrated with WordPress authentication. Configure backup codes and test the recovery process before enforcing it.
Application Passwords are reusable API credentials; they are not 2FA. Give integrations dedicated, least-privileged accounts and revoke credentials that are no longer needed.
In application code, check capabilities rather than role names:
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_die( esc_html__( 'You are not allowed to edit this post.', 'my-plugin' ), 403 );
}
A role is a collection of capabilities that can be changed. The capability check expresses the operation the user is attempting and lets WordPress apply object-level rules.
3. Validate input, sanitize deliberately, and escape late
These controls solve different problems:
- Validation asks whether the original value is acceptable. Prefer strict type, range, format, and allowlist checks, and reject invalid data when possible.
- Sanitization intentionally transforms data by removing or normalizing content.
- Escaping makes a value safe for the exact output context. Do it as late as possible, at the final output statement.
A value that was sanitized before storage still needs to be escaped when rendered.
Validate and sanitize incoming data
$email = isset( $_POST['email'] )
? sanitize_email( wp_unslash( $_POST['email'] ) )
: '';
if ( ! is_email( $email ) ) {
return new WP_Error( 'invalid_email', 'Enter a valid email address.' );
}
$visibility = isset( $_POST['visibility'] )
? sanitize_key( wp_unslash( $_POST['visibility'] ) )
: '';
$allowed_visibility = array( 'public', 'private' );
if ( ! in_array( $visibility, $allowed_visibility, true ) ) {
return new WP_Error( 'invalid_visibility', 'Invalid visibility value.' );
}
Sanitization is not a replacement for an allowlist. sanitize_key() can normalize a string, but the application still has to decide whether that normalized value is permitted.
Escape for the output context
<h2><?php echo esc_html( $title ); ?></h2>
<input
type="text"
value="<?php echo esc_attr( $value ); ?>"
/>
<a href="<?php echo esc_url( $url ); ?>">
<?php echo esc_html( $label ); ?>
</a>
<textarea><?php echo esc_textarea( $body ); ?></textarea>
<div class="formatted-content">
<?php echo wp_kses_post( $allowed_html ); ?>
</div>
Use the function that matches where the data is going. HTML text, attributes, URLs, textareas, JavaScript, and intentionally allowed HTML are different contexts.
4. Prefer WordPress APIs and prepare custom SQL
Use APIs such as WP_Query, metadata functions, the Options API, and wp_insert_post() for WordPress-managed data. These APIs do more than reduce SQL risk: they preserve hooks, validation, cache invalidation, status transitions, and other WordPress lifecycle behavior.
$published_posts = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 10,
'author' => get_current_user_id(),
)
);
$wpdb is appropriate for custom tables or specialized queries that core APIs cannot express. For straightforward writes, use $wpdb->insert(), $wpdb->update(), and $wpdb->delete(). Prepare dynamic values in custom SQL:
global $wpdb;
$status = 'publish';
$post_type = 'book';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title
FROM {$wpdb->posts}
WHERE post_status = %s
AND post_type = %s",
$status,
$post_type
)
);
Do not concatenate untrusted values into the query template. SQL keywords, operators, and sort directions need strict allowlists rather than value placeholders.
For a LIKE query, escape wildcard characters before preparing the pattern:
$search = isset( $_GET['search'] )
? sanitize_text_field( wp_unslash( $_GET['search'] ) )
: '';
$pattern = '%' . $wpdb->esc_like( $search ) . '%';
$sql = $wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_title LIKE %s",
$pattern
);
5. Nonces do not replace authorization
WordPress nonces help protect against cross-site request forgery by checking request intent. They are not authentication, authorization, or strict single-use replay protection.
A state-changing request generally needs separate layers:
- Verify the nonce.
- Check the user’s capability for the operation.
- Validate and sanitize the submitted data.
- Perform the operation through an appropriate API.
- Escape values when producing output.
function handle_book_update() {
check_admin_referer( 'update_book', 'book_nonce' );
$post_id = isset( $_POST['post_id'] )
? absint( $_POST['post_id'] )
: 0;
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_die( esc_html__( 'You are not allowed to edit this post.', 'my-plugin' ), 403 );
}
$title = isset( $_POST['title'] )
? sanitize_text_field( wp_unslash( $_POST['title'] ) )
: '';
if ( '' === $title ) {
wp_die( esc_html__( 'A title is required.', 'my-plugin' ), 400 );
}
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $title,
)
);
}
For AJAX handlers, check_ajax_referer() still needs a separate capability check. Also remember that is_admin() identifies an administrative request context; it does not prove the current user is an administrator.
6. Protect the login surface before PHP does the work
Stock WordPress does not provide comprehensive login rate limiting or account lockouts. Rate-limit authentication attempts at the CDN, WAF, reverse proxy, or web-server layer when possible so abusive requests are blocked before consuming PHP resources.
Protect /wp-login.php and, if authentication through it is enabled, /xmlrpc.php. When infrastructure controls are unavailable, use a maintained security plugin that provides throttling.
CAPTCHA and changing the login URL can add friction, but they do not replace rate limiting, unique credentials, least privilege, and 2FA. A short transient-based PHP snippet is not a production-grade brute-force control: it can be bypassed through identifier variation, consumes application resources, and needs careful handling for shared IPs and denial-of-service scenarios.
7. Use HTTPS across the entire site
Configure valid TLS, then set both the WordPress Address and Site Address to HTTPS. Redirect HTTP to HTTPS at the server, load balancer, or CDN layer.
Require HTTPS for authentication and administration:
// wp-config.php
define( 'FORCE_SSL_ADMIN', true );
Behind a reverse proxy, configure HTTPS detection correctly. Incorrect forwarded-protocol handling can cause redirect loops or insecure cookie behavior. HTTPS should cover the public site as well as /wp-admin.
8. Back up for recovery
A useful recovery set includes both:
- The database, including tables created by plugins.
- Files that cannot simply be reconstructed, especially
wp-content,wp-config.php, custom server configuration, and custom or otherwise non-redownloadable code.
Create database and filesystem backups at approximately the same time. Choose frequency according to how much data the site can afford to lose, keep several recent copies in different locations, and keep at least one copy outside the production hosting environment.
Most importantly, perform an isolated restore test periodically. A successful backup notification proves that a job ran; it does not prove that the files are complete, the database imports, or the application starts.
WordPress Tools → Export is useful for moving content, but it is not a complete disaster-recovery backup.
Security checklist
- Core runs a currently supported release
- Themes, plugins, PHP, and database software have a monitored update process
- Unused plugins and themes are removed
- Privileged users have unique credentials and 2FA
- Users and integrations have only the capabilities they need
- Input is validated, then sanitized only where transformation is appropriate
- Output is escaped for its final context
- Custom SQL uses
$wpdb->prepare()and strict allowlists where placeholders do not apply - State-changing requests verify a nonce and check capabilities separately
- Login endpoints are rate-limited outside the application when possible
- HTTPS covers the entire site
- Debug output is disabled in production
- Database and filesystem backups exist outside production
- Restore procedures are tested