LESSON 7 ⏱️ 14 min read

Security: Nonces, Sanitization & Capabilities

Security Fundamentals

Plugin security follows four principles:

  1. Authentication – Is the user logged in?
  2. Authorization – Can this user perform this action?
  3. Validation – Is the input data valid?
  4. Sanitization/Escaping – Is data safe for storage/display?

Nonces: Preventing CSRF Attacks

Nonces verify that requests come from legitimate forms:

// Creating a nonce
$nonce = wp_create_nonce( 'tm_delete_task' );

// In a URL
$delete_url = add_query_arg( [
    'action'  => 'delete',
    'task_id' => $task_id,
    '_wpnonce' => wp_create_nonce( "tm_delete_{$task_id}" ),
], admin_url( 'admin.php?page=task-manager' ) );

// In a form
<form method="post">
    <?php wp_nonce_field( 'tm_save_task', 'tm_nonce' ); ?>
    <!-- form fields -->
</form>

Verifying Nonces

// Verify form nonce
if ( ! wp_verify_nonce( $_POST['tm_nonce'] ?? '', 'tm_save_task' ) ) {
    wp_die( __( 'Security check failed.', 'task-manager' ) );
}

// Verify URL nonce (for admin actions)
if ( ! wp_verify_nonce( $_GET['_wpnonce'] ?? '', "tm_delete_{$task_id}" ) ) {
    wp_die( __( 'Invalid security token.', 'task-manager' ) );
}

// For AJAX - exits on failure
check_ajax_referer( 'tm_admin_nonce', 'nonce' );
Nonce Lifetime: Nonces are valid for 24 hours by default. For sensitive operations, you may want to add timestamp checks.

User Capabilities

Never trust user roles directly – check capabilities:

// ❌ Wrong - checking role
if ( in_array( 'administrator', wp_get_current_user()->roles ) ) {
    // do something
}

// ✅ Correct - checking capability
if ( current_user_can( 'manage_options' ) ) {
    // do something
}

Common Capabilities

CapabilityWho Has ItUse For
readAll logged-in usersView content
edit_postsAuthors+Create/edit content
publish_postsAuthors+Publish content
delete_postsAuthors+Delete own content
manage_optionsAdministratorsPlugin settings
install_pluginsSuper AdminsInstall plugins

Creating Custom Capabilities

// On activation
public static function activate(): void {
    // Add custom capability to admin role
    $admin_role = get_role( 'administrator' );
    $admin_role->add_cap( 'manage_tasks' );
    
    // Create custom role
    add_role( 'task_manager', __( 'Task Manager', 'task-manager' ), [
        'read'         => true,
        'manage_tasks' => true,
    ] );
}

// On uninstall
public static function uninstall(): void {
    remove_role( 'task_manager' );
    
    $admin_role = get_role( 'administrator' );
    $admin_role->remove_cap( 'manage_tasks' );
}

// Usage
if ( current_user_can( 'manage_tasks' ) ) {
    // User can manage tasks
}

Data Sanitization

Sanitize all input before storage or use:

String Sanitization

// Basic text - removes tags, encoded entities
$title = sanitize_text_field( $_POST['title'] );

// Textarea - preserves line breaks
$description = sanitize_textarea_field( $_POST['description'] );

// Email
$email = sanitize_email( $_POST['email'] );

// URL
$url = esc_url_raw( $_POST['url'] );

// Filename
$filename = sanitize_file_name( $_POST['filename'] );

// HTML class
$class = sanitize_html_class( $_POST['class'] );

// Key (lowercase, alphanumeric, dashes, underscores)
$status = sanitize_key( $_POST['status'] );

Number Sanitization

// Absolute integer (positive)
$id = absint( $_POST['id'] );

// Integer (can be negative)
$count = intval( $_POST['count'] );

// Float
$price = floatval( $_POST['price'] );

Array Sanitization

// Sanitize array of integers
$ids = array_map( 'absint', (array) $_POST['task_ids'] );

// Sanitize array of strings
$tags = array_map( 'sanitize_text_field', (array) $_POST['tags'] );

// Filter valid values only
$allowed = [ 'pending', 'in_progress', 'completed' ];
$statuses = array_intersect( (array) $_POST['statuses'], $allowed );

KSES: Allow Specific HTML

// Allow basic HTML
$content = wp_kses_post( $_POST['content'] );

// Allow custom tags/attributes
$allowed_html = [
    'a' => [
        'href'   => [],
        'title'  => [],
        'target' => [],
    ],
    'strong' => [],
    'em'     => [],
    'code'   => [],
];
$content = wp_kses( $_POST['content'], $allowed_html );

Output Escaping

Always escape when outputting:

// Escape HTML entities (most common)
echo esc_html( $task->title );

// Escape attributes
<input value="<?php echo esc_attr( $value ); ?>">

// Escape URLs
<a href="<?php echo esc_url( $url ); ?>">

// Escape JavaScript strings
<script>var data = <?php echo esc_js( $data ); ?>;</script>

// Escape for JSON in attributes
<div data-config='<?php echo esc_attr( wp_json_encode( $config ) ); ?>'>

// Escape with translation
echo esc_html__( 'Hello World', 'task-manager' );
echo esc_html_e( 'Hello World', 'task-manager' ); // echoes directly

// Escape with placeholders
printf(
    esc_html__( 'Task %s created by %s', 'task-manager' ),
    esc_html( $task->title ),
    esc_html( $user->display_name )
);

Complete Security Example

<?php
// Secure form processing

public function handle_form_submission(): void {
    // 1. Check if form was submitted
    if ( ! isset( $_POST['tm_submit'] ) ) {
        return;
    }
    
    // 2. Verify nonce (CSRF protection)
    if ( ! wp_verify_nonce( $_POST['tm_nonce'] ?? '', 'tm_create_task' ) ) {
        wp_die( 
            __( 'Security verification failed.', 'task-manager' ),
            __( 'Error', 'task-manager' ),
            [ 'response' => 403, 'back_link' => true ]
        );
    }
    
    // 3. Check user capabilities (authorization)
    if ( ! current_user_can( 'manage_tasks' ) ) {
        wp_die(
            __( 'You do not have permission to create tasks.', 'task-manager' ),
            __( 'Permission Denied', 'task-manager' ),
            [ 'response' => 403, 'back_link' => true ]
        );
    }
    
    // 4. Validate required fields
    $title = isset( $_POST['title'] ) ? trim( $_POST['title'] ) : '';
    if ( empty( $title ) ) {
        $this->add_error( 'title', __( 'Title is required.', 'task-manager' ) );
        return;
    }
    
    // 5. Validate field formats
    $status = $_POST['status'] ?? 'pending';
    if ( ! in_array( $status, [ 'pending', 'in_progress', 'completed' ], true ) ) {
        $status = 'pending';
    }
    
    $priority = absint( $_POST['priority'] ?? 1 );
    $priority = max( 1, min( 5, $priority ) ); // Clamp to 1-5
    
    // 6. Sanitize data
    $data = [
        'title'       => sanitize_text_field( $title ),
        'description' => wp_kses_post( $_POST['description'] ?? '' ),
        'status'      => sanitize_key( $status ),
        'priority'    => $priority,
        'due_date'    => $this->validate_date( $_POST['due_date'] ?? '' ),
    ];
    
    // 7. Create record
    $task_id = $this->db->create( $data );
    
    if ( ! $task_id ) {
        $this->add_error( 'general', __( 'Failed to create task.', 'task-manager' ) );
        return;
    }
    
    // 8. Redirect with success message
    wp_safe_redirect(
        add_query_arg( 'message', 'created', admin_url( 'admin.php?page=task-manager' ) )
    );
    exit;
}

private function validate_date( string $date ): ?string {
    if ( empty( $date ) ) {
        return null;
    }
    
    $timestamp = strtotime( $date );
    if ( ! $timestamp ) {
        return null;
    }
    
    return date( 'Y-m-d H:i:s', $timestamp );
}

Security Checklist

CheckMethod
CSRF Protectionwp_nonce_field(), check_admin_referer()
Authorizationcurrent_user_can()
Input Sanitizationsanitize_() functions
Output Escapingesc_() functions
SQL Injection$wpdb->prepare()
Direct File Accessif ( ! defined( 'ABSPATH' ) ) exit;
File Uploadswp_check_filetype(), size limits

Next Steps

In our final lesson, we'll package the plugin for distribution, handle translations, and prepare for WordPress.org submission.

🎯 Lesson Complete! Your plugin is now secure against common vulnerabilities.