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. - Add a nonce to a form
- Use
wp_nonce_field()to output hidden form fields for the nonce. - Create a nonce manually
- Use
wp_create_nonce()to generate a nonce string, often for use in AJAX requests. - Verify a nonce
- Use
wp_verify_nonce()for conditional logic. It returnstrue(for a valid nonce) orfalse.
$complete_url = wp_nonce_url( $bare_url, 'trash-post_' . $post->ID );
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" />
$nonce = wp_create_nonce( 'my-action_' . $post->ID );
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>. - esc_attr()
- Use for data being placed inside an HTML attribute, like
classorid. - esc_url()
- Use for all URLs, especially in
srcandhrefattributes. - esc_js()
- Use for data being placed into inline JavaScript, such as an
onclickattribute. - esc_textarea()
- Use for displaying text inside a
<textarea>element.
<h4><?php echo esc_html( $title ); ?></h4>
<ul class="<?php echo esc_attr( $stored_class ); ?>">
<a href="<?php echo esc_url( $link_url ); ?>">Link</a>
<a href="#" onclick="doSomething('<?php echo esc_js( $var ); ?>');">
<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.