Best Practices for WordPress Security

A step-by-step guide to secure development.

1. Gate by Capability (Authorization)

The first and most important step is ensuring a user is allowed to perform an action. WordPress uses roles and capabilities to control permissions. A role is a collection of capabilities that define what a user can do. Always check capabilities, not roles.

Default WordPress Roles

  • Super Admin - Multisite network admin (has all capabilities).
  • Administrator - Single site admin (can manage everything on a site).
  • Editor - Can publish and manage all posts and pages.
  • Author - Can publish and manage their own posts.
  • Contributor - Can write posts but cannot publish them.
  • Subscriber - Can read posts and manage their own profile.

Checking Capabilities

The function current_user_can() is the correct way to check if the current user has permission to perform an action. This check should be performed as early as possible.

// Check if user can perform a general action
if ( current_user_can( 'edit_posts' ) ) {
    // User can edit posts
}

// Check for an admin-level capability
if ( current_user_can( 'manage_options' ) ) {
    // User is likely an admin
}

// Check capability for a specific object (e.g., a post)
if ( current_user_can( 'edit_post', $post_id ) ) {
    // User can edit this specific post
}

2. Use Nonces to Prevent CSRF

A nonce (“number used once”) in WordPress is a unique security token generated to validate that a user request is intentional and originates from an authorized source. Their main goal is to protect against Cross-Site Request Forgery (CSRF) attacks, which occur when an attacker tricks a logged-in user into performing unwanted actions.

Note that WordPress nonces aren’t true cryptographic nonces. They can be reused by the same user for the same action within a limited timeframe (typically 12–24 hours).

Key Properties and Limitations

  • Nonces are not for authentication or authorization. Always use current_user_can() to check if a user has permission.
  • Nonces are specific to a user, session, action, and object.
  • They expire after a set lifetime (default: 24 hours).
  • They are included in links (URLs), forms (as hidden fields), or AJAX requests.

How Do Nonces Work?

A nonce adds a unique, temporary token to a sensitive action. WordPress can then verify this token before proceeding. If the token is missing or invalid, it blocks the action.

Without a nonce:

http://yourwebsite.com/wp-admin/post.php?post=123&action=trash

With a nonce:

http://yourwebsite.com/wp-admin/post.php?post=123&action=trash&_wpnonce=b192fc4204

Core Nonce Functions

Add a nonce to a URL
Use wp_nonce_url() to append a correctly formatted nonce to a URL.
$complete_url = wp_nonce_url( $bare_url, 'trash-post_' . $post->ID );
Add a nonce to a form
Use wp_nonce_field() to output hidden form fields for the nonce.
wp_nonce_field( 'delete-comment_' . $comment_id );

This produces:

<input type="hidden" name="_wpnonce" value="796c7766b1" />
<input type="hidden" name="_wp_http_referer" value="/wp-admin/edit-comments.php" />
Create a nonce manually
Use wp_create_nonce() to generate a nonce string, often for use in AJAX requests.
$nonce = wp_create_nonce( 'my-action_' . $post->ID );
Verify a nonce
Use wp_verify_nonce() for conditional logic. It returns true (for a valid nonce) or false.
if ( isset($_POST['_wpnonce']) && wp_verify_nonce($_POST['_wpnonce'], 'my-action') ) {
    // Nonce is valid, proceed with action
} else {
    // Invalid nonce, block action
    wp_die('Security check failed!');
}

3. Validate Before You Sanitize

Validation tests data against predefined patterns with a definitive result: valid or invalid. It is more specific than sanitization and should always be performed first. It's better to reject invalid data than to try and clean it.

Safelist Approach (Recommended)

The best validation strategy is to accept data only if it matches a known list of trusted values. Always use strict comparison (true as the third parameter in in_array()).

$allowed_keys = array( 'author', 'date', 'title' );
$orderby = sanitize_key( $_POST['orderby'] ); // Sanitize first to simplify

if ( in_array( $orderby, $allowed_keys, true ) ) {
    // Valid data - proceed
} else {
    wp_die( 'Invalid sort key provided.' );
}

Format Detection

You can also test if data matches an expected format, like a ZIP code or phone number, using regular expressions.

// US ZIP code validation
function wporg_is_valid_us_zip_code( string $zip_code ): bool {
    if ( ! preg_match( '/^\d{5}(-?\d{4})?$/', $zip_code ) ) {
        return false;
    }
    return true;
}

Format Correction

Accept most any data, but remove or alter the dangerous pieces.

$trusted_integer = (int) $untrusted_integer;
$trusted_alpha = preg_replace( '/[^a-z]/i', "", $untrusted_alpha );
$trusted_slug = sanitize_title( $untrusted_slug );

Essential Validation Functions

  • is_email() - Validates an email address.
  • term_exists() - Checks if a taxonomy term exists.
  • username_exists() - Checks if a username exists.
  • validate_file() - Validates a file path.
  • preg_match() - General-purpose pattern matching.

4. Sanitize at Input Boundaries

After data is validated or if validation is not possible, sanitization is the process of cleaning it to remove potentially harmful content and ensure it's in a safe, expected format. It's your second line of defense for data you intend to accept and use.

Key Function: sanitize_text_field()

This is the most common sanitization function for single-line text inputs.

$title = sanitize_text_field( $_POST['title'] );
update_post_meta( $post->ID, 'title', $title );

Behind the scenes, this function performs several actions:

  • Checks for invalid UTF-8 characters.
  • Converts single less-than characters (<) to HTML entities.
  • Strips all HTML tags.
  • Removes line breaks, tabs, and extra whitespace.

Essential Sanitization Functions

  • sanitize_email() - For email addresses.
  • sanitize_url() - For URLs and links.
  • sanitize_file_name() - For cleaning file names.
  • sanitize_textarea_field() - For multi-line text fields.
  • wp_kses() / wp_kses_post() - For allowing specific, safe HTML.

5. Escape on Output

Escaping secures data right before it is rendered to the user, preventing Cross-Site Scripting (XSS). This is a critical last-mile defense. Always escape as late as possible, choosing the function that matches the output context.

Core Escaping Functions

esc_html()
Use when displaying data directly inside an HTML element like <p> or <h4>.
<h4><?php echo esc_html( $title ); ?></h4>
esc_attr()
Use for data being placed inside an HTML attribute, like class or id.
<ul class="<?php echo esc_attr( $stored_class ); ?>">
esc_url()
Use for all URLs, especially in src and href attributes.
<a href="<?php echo esc_url( $link_url ); ?>">Link</a>
esc_js()
Use for data being placed into inline JavaScript, such as an onclick attribute.
<a href="#" onclick="doSomething('<?php echo esc_js( $var ); ?>');">
esc_textarea()
Use for displaying text inside a <textarea> element.
<textarea><?php echo esc_textarea( $data ); ?></textarea>

Annotated Full Example

This example combines all the principles in the correct order to create a secure "Delete Post" link on the front-end for privileged users.

// Register behavior only for privileged roles
add_action('plugins_loaded', function () {
    if (! current_user_can('edit_others_posts')) {
        return;
    }
    add_filter('the_content', 'wporg_generate_delete_link');
    add_action('init', 'wporg_delete_post');
});

// Show link securely to eligible users
function wporg_generate_delete_link($content) {
    if (! (is_single() && in_the_loop() && is_main_query())) {
        return $content;
    }
    if (! current_user_can('edit_others_posts')) {
        return $content;
    }
    $post_id = get_the_ID();
    $url = add_query_arg([
        'action'   => 'wporg_frontend_delete',
        'post'     => $post_id,
        '_wpnonce' => wp_create_nonce('wporg_frontend_delete_' . $post_id),
    ], home_url());

    return $content . ' <a href="' . esc_url($url) . '">' . esc_html__('Delete Post', 'wporg') . '</a>';
}

// Handle the action with the correct security flow
function wporg_delete_post() {
    if (! isset($_GET['action']) || $_GET['action'] !== 'wporg_frontend_delete') {
        return;
    }

    // 1) Authorization
    if (! current_user_can('edit_others_posts')) {
        wp_die(__('You are not allowed to perform this action.', 'wporg'), 403);
    }

    // 2) CSRF protection (Nonce)
    $post_id = isset($_GET['post']) ? (int) $_GET['post'] : 0;
    if (! isset($_GET['_wpnonce']) || ! wp_verify_nonce($_GET['_wpnonce'], 'wporg_frontend_delete_' . $post_id)) {
        wp_nonce_ays('wporg_frontend_delete_' . $post_id);
        exit;
    }

    // 3) Validation (existence + per-object permission)
    $post = $post_id ? get_post($post_id) : null;
    if (! $post) {
        return;
    }
    if (! current_user_can('delete_post', $post_id)) {
        wp_die(__('You cannot delete this post.', 'wporg'), 403);
    }

    // 4) Execute
    wp_trash_post($post_id);

    // 5) Safe redirect
    wp_safe_redirect(admin_url('edit.php'));
    exit;
}

Why This Order Matters

  • Capability checks first stop unauthorized users before any other work is done. It's the most efficient check.
  • Nonce check next ensures the authorized user actually intended to perform the action, mitigating CSRF attacks.
  • Validation ensures inputs are correct, acceptable, and exist (e.g., the post ID is valid).
  • Escaping is the last-mile defense at the output boundary, applied with full context of where the data is going.