Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 032 / 100

WordPress security checklist: what developers should know

Protect sites and plugins.

Expona Admin8 min read

WordPress security checklist: what developers should know

Security vulnerabilities in WordPress plugins and themes cause thousands of site breaches every year. A single unescaped output or missing nonce can expose user data, allow arbitrary code execution, or hand control of your client's site to attackers. This WordPress security checklist covers the four fundamentals every developer must implement before shipping code to production.

Escape output — esc_html, esc_attr and esc_url

Output escaping is your first line of defense. Whenever data leaves your PHP code and enters HTML, JavaScript, or CSS context, it must be escaped appropriately. WordPress provides context-specific escaping functions that encode dangerous characters, preventing injection attacks.

The most common escaping function is esc_html(). Use it whenever you output user-generated content or dynamic data inside HTML tags. This function converts characters like <, >, and " into their HTML entities.

php
<?php
// BAD: Direct output without escaping
echo $user_message;

// GOOD: Escaped output
echo esc_html( $user_message );

// This converts <script>alert('xss')</script> 
// to &lt;script&gt;alert('xss')&lt;/script&gt;
?>

For attributes, use esc_attr(). This function is necessary when your variable fills an HTML attribute like title, data-id, or class.

php
<?php
// GOOD: Escaped attribute
echo '<div title="' . esc_attr( $tooltip_text ) . '">';

// GOOD: Escaped class attribute
echo '<button class="' . esc_attr( $button_class ) . '">';
?>

For URLs, use esc_url(). This function validates and encodes URLs to prevent javascript: protocol attacks and other malicious URIs.

php
<?php
// GOOD: Escaped URL
echo '<a href="' . esc_url( $redirect_url ) . '">Click here</a>';

// esc_url() will strip out javascript:alert('xss') and similar threats
?>

When outputting HTML that you've generated yourself (not user content), use wp_kses_post() to allow safe HTML tags while stripping dangerous ones. This is useful for plugin settings or admin notices that should support basic formatting.

php
<?php
$safe_html = '<p>Hello <strong>world</strong></p>';
echo wp_kses_post( $safe_html );
// Allows <p>, <strong>, <em>, etc. but strips <script>
?>

Sanitize input — sanitize_text_field and friends

Sanitization cleans incoming data before you store or use it. Unlike escaping (which happens on output), sanitization happens on input and removes or transforms unwanted content. Always sanitize $_POST, $_GET, and $_REQUEST data immediately.

For general text input, use sanitize_text_field(). This function removes HTML tags, encodes special characters, and trims whitespace—perfect for usernames, email subjects, or product titles.

php
<?php
if ( isset( $_POST['user_bio'] ) ) {
    $user_bio = sanitize_text_field( $_POST['user_bio'] );
    update_user_meta( $user_id, 'bio', $user_bio );
}

// sanitize_text_field() strips all HTML, converts < to &lt;, etc.
?>

For email addresses, use sanitize_email(). It validates the email format and removes invalid characters.

php
<?php
if ( isset( $_POST['email'] ) ) {
    $email = sanitize_email( $_POST['email'] );
    
    if ( is_email( $email ) ) {
        // Process the email
    }
}
?>

For URLs and file paths, use esc_url_raw() (for database storage) or sanitize_file_name() (for uploaded files).

php
<?php
// Sanitize a URL before saving to database
$homepage_url = esc_url_raw( $_POST['homepage_url'] );
update_option( 'homepage_url', $homepage_url );

// Sanitize an uploaded filename
$filename = sanitize_file_name( $_FILES['image']['name'] );
?>

For serialized data or JSON, validate the structure after unserializing. Don't assume incoming data matches your expected format.

php
<?php
if ( isset( $_POST['settings_json'] ) ) {
    $data = json_decode( wp_unslash( $_POST['settings_json'] ), true );
    
    // Validate structure
    if ( is_array( $data ) && isset( $data['color'] ) ) {
        $color = sanitize_hex_color( $data['color'] );
    }
}
?>

Nonces — Protect forms and AJAX against CSRF

A nonce (number used once) is a security token that verifies a request originated from your site. Without nonces, attackers can trick users into performing unwanted actions via cross-site request forgery (CSRF). Every form submission and AJAX request should include and verify a nonce.

To create a nonce, use wp_nonce_field() in your form HTML. This generates a hidden input field containing the token.

php
<?php
// In your form template:
?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>">
    <?php wp_nonce_field( 'save_user_settings', 'user_settings_nonce' ); ?>
    <input type="text" name="display_name" />
    <button type="submit">Save</button>
</form>

On the server side, verify the nonce with wp_verify_nonce() before processing the request. If verification fails, reject the request.

php
<?php
if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) {
    // Verify nonce
    $nonce = isset( $_POST['user_settings_nonce'] ) ? $_POST['user_settings_nonce'] : '';
    
    if ( ! wp_verify_nonce( $nonce, 'save_user_settings' ) ) {
        wp_die( 'Security check failed' );
    }
    
    // Nonce is valid, process the form
    $display_name = sanitize_text_field( $_POST['display_name'] );
    update_user_meta( get_current_user_id(), 'display_name', $display_name );
}
?>

For AJAX requests, include the nonce in the JavaScript data and verify it on the PHP side. WordPress provides wp_localize_script() to pass nonces to frontend JavaScript safely.

php
<?php
// In your plugin PHP:
wp_localize_script( 'my-plugin-js', 'myPlugin', array(
    'nonce' => wp_create_nonce( 'delete_user_data' ),
) );

// In your JavaScript:
// fetch( '/wp-admin/admin-ajax.php', {
//     method: 'POST',
//     body: new FormData(),
//     headers: {
//         'X-WP-Nonce': myPlugin.nonce
//     }
// });

// Back in PHP handler:
check_ajax_referer( 'delete_user_data', 'nonce' );
// If nonce is invalid, this function dies
?>

Capabilities — current_user_can before every privileged action

Never assume the current user has permission to perform an action. Always check capabilities with current_user_can() before executing privileged code. This prevents users from escalating privileges or accessing data they shouldn't.

WordPress roles (subscriber, contributor, author, editor, administrator) each have specific capabilities. Use the appropriate capability for your action: edit_posts, delete_users, manage_options, etc.

php
<?php
// GOOD: Check capability before deleting a post
if ( ! current_user_can( 'delete_posts' ) ) {
    wp_die( 'You do not have permission to delete posts' );
}

wp_delete_post( $post_id );

// GOOD: Check for admin capability before accessing settings
if ( ! current_user_can( 'manage_options' ) ) {
    wp_die( 'You do not have permission to manage plugin settings' );
}

update_option( 'my_plugin_setting', $value );
?>

For plugin-specific capabilities, define custom capabilities and assign them to roles. This gives you granular control over who can perform specific actions.

php
<?php
// Define a custom capability on plugin activation
function my_plugin_activate() {
    $role = get_role( 'administrator' );
    $role->add_cap( 'manage_my_plugin' );
}
register_activation_hook( __FILE__, 'my_plugin_activate' );

// Check the custom capability
if ( ! current_user_can( 'manage_my_plugin' ) ) {
    wp_die( 'You cannot manage this plugin' );
}
?>

For custom post types, use post-type-specific capabilities like edit_my_cpt or delete_my_cpt. Check these before CRUD operations on that post type.

php
<?php
// Register custom post type with custom capabilities
register_post_type( 'invoice', array(
    'capability_type' => 'invoice',
    'capabilities'    => array(
        'publish_posts'       => 'publish_invoices',
        'edit_posts'          => 'edit_invoices',
        'delete_posts'        => 'delete_invoices',
    ),
) );

// Check before editing an invoice
if ( ! current_user_can( 'edit_invoices' ) ) {
    wp_die( 'You cannot edit invoices' );
}
?>

Common mistakes

  • Forgetting to escape in JSON or JavaScript context. Use json_encode() and ensure the result is also escaped for the HTML/JS context where it appears. A simple esc_html() may not be enough for complex JSON.
  • Sanitizing on output instead of input. Sanitize when you receive data, not when you display it. By the time data reaches output, it should already be clean and you should escape it.
  • Using the wrong nonce action string. The first argument to wp_nonce_field() must match the first argument to wp_verify_nonce(). A typo breaks CSRF protection silently.
  • Checking capabilities only in the UI, not in the code. Hide buttons for unauthorized users, but always verify capabilities server-side. A determined attacker can craft requests directly.
  • Trusting user roles over capabilities. Check current_user_can( 'capability' ), not the user's role name. Roles change; capabilities define actual permissions.

Key takeaways

  • Escape output context-specifically: esc_html() for HTML, esc_attr() for attributes, esc_url() for URLs.
  • Sanitize all user input immediately on receipt with sanitize_text_field(), sanitize_email(), or other context-specific functions.
  • Add nonces to every form and AJAX request; verify them server-side with wp_verify_nonce() or check_ajax_referer().
  • Call current_user_can() before any privileged action; never trust the user to enforce permissions themselves.
  • Escape on output, sanitize on input, nonces on requests, capabilities before actions. These four practices stop the vast majority of common WordPress vulnerabilities.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles