Security Best Practices
Because WordPress powers such a huge share of the web, it's a constant target for attackers. Most real-world WordPress vulnerabilities aren't exotic — they come from a handful of well understood mistakes: trusting user input, forgetting to check permissions, or building SQL queries by hand. This page covers the core defensive habits every WordPress developer needs.
1. Nonces: CSRF Protection
A nonce ("number used once") is a short-lived token WordPress generates to prove that a form
submission or AJAX request genuinely came from a page it rendered — not from a malicious site
tricking a logged-in user's browser into submitting a hidden form (a Cross-Site Request Forgery,
or CSRF, attack). A nonce is created with wp_create_nonce() and checked with
wp_verify_nonce() or check_admin_referer().
// In the form template
<form method="post">
<?php wp_nonce_field('save_profile_action', 'profile_nonce'); ?>
<input type="text" name="display_name">
<input type="submit" value="Save">
</form>
<?php
// In the handler that processes the submission
function handle_profile_save() {
if (
!isset($_POST['profile_nonce']) ||
!wp_verify_nonce($_POST['profile_nonce'], 'save_profile_action')
) {
wp_die('Security check failed.');
}
// ...safe to proceed
}
wp_create_nonce()/wp_nonce_field() and wp_verify_nonce() must match exactly — it acts like a namespace, so a nonce created for one action can't be replayed against a different one.2. Sanitize Input vs. Escape Output
This distinction trips up a lot of WordPress developers, but the rule is simple once it clicks: sanitize on the way in, escape on the way out. Sanitizing cleans data before it's saved (e.g. to the database); escaping makes data safe right at the moment it's printed into HTML, to prevent Cross-Site Scripting (XSS).
| Function | When | Purpose |
|---|---|---|
sanitize_text_field() | Input (in) | Strips tags, extra whitespace, and line breaks from a plain text field before it's stored |
esc_html() | Output (out) | Escapes a string so it prints as visible text, not interpreted HTML — use when echoing text between tags |
esc_attr() | Output (out) | Escapes a string for safe use inside an HTML attribute, e.g. value="<?php echo esc_attr($x); ?>" |
esc_url() | Output (out) | Escapes and validates a URL before it's placed in href or src |
3. Always Use $wpdb->prepare() for Custom Queries
Any time a custom SQL query includes a variable — a post ID, a search term, anything from user
input — that value must go through $wpdb->prepare(), which uses placeholders
(%s, %d, %f) to safely escape values, exactly like the
prepared statements covered in The WordPress Database.
<?php
global $wpdb;
// Never do this — vulnerable to SQL injection:
// $wpdb->get_results("SELECT * FROM wp_posts WHERE post_author = $author_id");
// Always do this instead:
$results = $wpdb->get_results(
$wpdb->prepare("SELECT * FROM {$wpdb->posts} WHERE post_author = %d", $author_id)
);
4. Capability Checks Before Sensitive Actions
Before allowing any sensitive action — showing an admin settings page, deleting data, changing a
setting — always verify the current user is actually allowed to do it, with
current_user_can().
<?php
function render_plugin_settings_page() {
if (!current_user_can('manage_options')) {
wp_die('You do not have permission to access this page.');
}
// ...render the settings form
}
manage_options is the capability administrators hold; other roles (editor, author, subscriber) hold different, more limited capabilities — always check the specific capability the action actually requires, not just whether someone is "logged in."5. Keep Core, Themes, and Plugins Updated
A huge share of real-world WordPress hacks exploit known, already-patched vulnerabilities in outdated software — not zero-days. Updating WordPress core, the active theme, and every plugin promptly is one of the simplest and most effective security practices available.
6. Putting It All Together
A realistic form handler combines every technique above: verify the nonce, sanitize the input, check the capability, and only then save the data.
<?php
function handle_site_tagline_update() {
// 1. Verify the nonce (CSRF protection)
if (
!isset($_POST['tagline_nonce']) ||
!wp_verify_nonce($_POST['tagline_nonce'], 'update_tagline')
) {
wp_die('Security check failed.');
}
// 2. Check the capability
if (!current_user_can('manage_options')) {
wp_die('You do not have permission to do this.');
}
// 3. Sanitize the raw input — never trust $_POST directly
$tagline = sanitize_text_field($_POST['tagline'] ?? '');
// 4. Now it's safe to save
update_option('blogdescription', $tagline);
wp_redirect(admin_url('options-general.php?updated=true'));
exit;
}