Skip to main content

Course Progress

Loading...

🔒 Theme Security Best Practices

Protect your WordPress themes from vulnerabilities

Master validation, sanitization, escaping, and defense against attacks

Learning Objectives

  • Understand common WordPress security vulnerabilities
  • Master data validation and sanitization
  • Implement proper output escaping
  • Use WordPress nonces for CSRF protection
  • Prevent SQL injection attacks
  • Secure file uploads and includes
  • Implement proper user capability checks
  • Follow security coding standards

Understanding WordPress Security

Security is not optional in WordPress theme development. A single vulnerability can compromise an entire website and its users' data.

The Security Mindset

  • Never trust user input: Always validate and sanitize
  • Escape late, escape often: Escape output right before display
  • Principle of least privilege: Check capabilities before actions
  • Defense in depth: Multiple layers of security
  • Keep it simple: Complex code is harder to secure
💡
Key Principle
The golden rule of WordPress security: "Validate input, sanitize data, escape output, and verify nonces."

Common WordPress Vulnerabilities

Cross-Site Scripting (XSS) CRITICAL

Malicious scripts injected into web pages

  • Stored XSS in database
  • Reflected XSS in URLs
  • DOM-based XSS
Prevention: Escape all output

SQL Injection CRITICAL

Malicious SQL queries executed on database

  • Direct query manipulation
  • Second-order injection
  • Blind SQL injection
Prevention: Use prepared statements

CSRF Attacks HIGH

Forged requests from authenticated users

  • State-changing operations
  • Admin actions
  • Form submissions
Prevention: Use WordPress nonces

File Inclusion HIGH

Unauthorized file access or execution

  • Local file inclusion
  • Remote file inclusion
  • Directory traversal
Prevention: Validate file paths

Privilege Escalation MEDIUM

Unauthorized access to admin functions

  • Missing capability checks
  • Insecure direct references
  • Broken access control
Prevention: Check user capabilities

Information Disclosure LOW

Exposure of sensitive information

  • Error messages
  • Debug information
  • Version numbers
Prevention: Disable debug in production

Data Validation and Sanitization

Input Validation Functions

<?php
/**
 * Validate and sanitize different types of input
 */

// Text input validation
$text = isset( $_POST['text_field'] ) ? sanitize_text_field( $_POST['text_field'] ) : '';

// Email validation
$email = isset( $_POST['email'] ) ? sanitize_email( $_POST['email'] ) : '';
if ( ! is_email( $email ) ) {
    wp_die( 'Invalid email address' );
}

// URL validation
$url = isset( $_POST['url'] ) ? esc_url_raw( $_POST['url'] ) : '';
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
    wp_die( 'Invalid URL' );
}

// Textarea with HTML
$content = isset( $_POST['content'] ) ? wp_kses_post( $_POST['content'] ) : '';

// Number validation
$number = isset( $_POST['number'] ) ? absint( $_POST['number'] ) : 0;
if ( $number < 1 || $number > 100 ) {
    wp_die( 'Number must be between 1 and 100' );
}

// Array validation
$options = isset( $_POST['options'] ) && is_array( $_POST['options'] ) 
    ? array_map( 'sanitize_text_field', $_POST['options'] ) 
    : array();

// Boolean validation
$checkbox = isset( $_POST['checkbox'] ) && $_POST['checkbox'] === '1';

// Select dropdown validation
$allowed_values = array( 'option1', 'option2', 'option3' );
$selected = isset( $_POST['select'] ) && in_array( $_POST['select'], $allowed_values, true ) 
    ? $_POST['select'] 
    : 'option1';

// File name validation
$filename = isset( $_POST['filename'] ) ? sanitize_file_name( $_POST['filename'] ) : '';

// SQL data validation
$title = isset( $_POST['title'] ) ? sanitize_title( $_POST['title'] ) : '';

// Key validation (alphanumeric + underscore/dash)
$key = isset( $_POST['key'] ) ? sanitize_key( $_POST['key'] ) : '';

// Custom validation function
function validate_color_hex( $color ) {
    $color = sanitize_hex_color( $color );
    if ( empty( $color ) ) {
        return '#000000'; // Default color
    }
    return $color;
}

// Date validation
function validate_date( $date, $format = 'Y-m-d' ) {
    $d = DateTime::createFromFormat( $format, $date );
    return $d && $d->format( $format ) === $date ? $date : false;
}

// Phone number validation
function validate_phone( $phone ) {
    $phone = preg_replace( '/[^0-9+()-]/', '', $phone );
    if ( strlen( $phone ) < 10 ) {
        return false;
    }
    return $phone;
}
Function Purpose Use Case
sanitize_text_field() Strips tags, removes line breaks Single line text inputs
sanitize_textarea_field() Preserves line breaks Multi-line text inputs
sanitize_email() Strips illegal email characters Email addresses
sanitize_url() Cleans URL for database URLs for storage
sanitize_file_name() Removes special characters File names
sanitize_html_class() Sanitizes HTML class names CSS classes
sanitize_key() Lowercase alphanumeric Database keys
sanitize_title() Sanitizes for URLs Post slugs
wp_kses_post() Allows safe HTML Post content
wp_kses() Custom allowed HTML Custom HTML filtering

Output Escaping

Escaping Functions

<?php
/**
 * Always escape output before displaying
 */

// HTML escaping
?>
<h1><?php echo esc_html( $title ); ?></h1>
<p><?php echo esc_html( $description ); ?></p>

<!-- Attribute escaping -->
<input type="text" value="<?php echo esc_attr( $value ); ?>" 
       class="<?php echo esc_attr( $class ); ?>"
       data-id="<?php echo esc_attr( $id ); ?>">

<!-- URL escaping -->
<a href="<?php echo esc_url( $link ); ?>">Link</a>
<img src="<?php echo esc_url( $image_url ); ?>" alt="<?php echo esc_attr( $alt ); ?>">

<!-- JavaScript escaping -->
<script>
var data = <?php echo wp_json_encode( $data ); ?>;
var message = '<?php echo esc_js( $message ); ?>';
</script>

<!-- Textarea escaping -->
<textarea><?php echo esc_textarea( $content ); ?></textarea>

<?php
// Translation with escaping
echo esc_html__( 'Hello World', 'textdomain' );
echo esc_attr__( 'Title attribute', 'textdomain' );
echo esc_html_e( 'Echo translated', 'textdomain' );

// Escaping with allowed HTML
$allowed_html = array(
    'a' => array(
        'href' => array(),
        'title' => array(),
        'target' => array(),
    ),
    'br' => array(),
    'em' => array(),
    'strong' => array(),
    'p' => array(
        'class' => array(),
    ),
);
echo wp_kses( $html_content, $allowed_html );

// Complex escaping example
function display_user_content( $content ) {
    // Allow only specific HTML tags
    $allowed_tags = array(
        'p' => array(),
        'br' => array(),
        'strong' => array(),
        'em' => array(),
        'a' => array(
            'href' => array(),
            'title' => array(),
        ),
        'ul' => array(),
        'ol' => array(),
        'li' => array(),
        'blockquote' => array(
            'cite' => array(),
        ),
    );
    
    // Sanitize and escape
    $content = wp_kses( $content, $allowed_tags );
    
    // Auto-paragraph
    $content = wpautop( $content );
    
    // Convert URLs to links
    $content = make_clickable( $content );
    
    return $content;
}

// Conditional escaping
function output_link( $url, $text, $new_window = false ) {
    if ( ! empty( $url ) ) {
        $target = $new_window ? ' target="_blank" rel="noopener noreferrer"' : '';
        printf(
            '<a href="%s"%s>%s</a>',
            esc_url( $url ),
            $target,
            esc_html( $text )
        );
    }
}

WordPress Nonces (CSRF Protection)

Implementing Nonces

<?php
/**
 * Form with nonce field
 */
?>
<form method="post" action="">
    <?php wp_nonce_field( 'my_form_action', 'my_form_nonce' ); ?>
    <input type="text" name="user_input" />
    <input type="submit" value="Submit" />
</form>

<?php
/**
 * Verify nonce on form submission
 */
if ( isset( $_POST['my_form_nonce'] ) ) {
    if ( ! wp_verify_nonce( $_POST['my_form_nonce'], 'my_form_action' ) ) {
        wp_die( 'Security check failed' );
    }
    
    // Process form data
    $user_input = sanitize_text_field( $_POST['user_input'] );
    // ... handle the data
}

/**
 * AJAX with nonce
 */
// In PHP - localize script
wp_localize_script( 'my-script', 'my_ajax', array(
    'ajax_url' => admin_url( 'admin-ajax.php' ),
    'nonce' => wp_create_nonce( 'my_ajax_nonce' ),
) );

// AJAX handler
add_action( 'wp_ajax_my_action', 'handle_ajax_request' );
add_action( 'wp_ajax_nopriv_my_action', 'handle_ajax_request' );

function handle_ajax_request() {
    // Verify nonce
    if ( ! check_ajax_referer( 'my_ajax_nonce', 'nonce', false ) ) {
        wp_die( 'Security check failed' );
    }
    
    // Process AJAX request
    $data = sanitize_text_field( $_POST['data'] );
    
    wp_send_json_success( array(
        'message' => 'Data processed successfully',
        'data' => $data
    ) );
}

/**
 * URL with nonce
 */
$delete_url = wp_nonce_url( 
    admin_url( 'admin.php?action=delete&id=' . $id ),
    'delete_item_' . $id
);

// Verify URL nonce
if ( isset( $_GET['action'] ) && $_GET['action'] === 'delete' ) {
    $id = absint( $_GET['id'] );
    
    if ( ! wp_verify_nonce( $_GET['_wpnonce'], 'delete_item_' . $id ) ) {
        wp_die( 'Security check failed' );
    }
    
    // Delete item
    delete_item( $id );
}

/**
 * Custom nonce implementation
 */
class My_Theme_Security {
    
    /**
     * Generate form with nonce
     */
    public static function form_open( $action = '', $method = 'post' ) {
        $output = '<form action="' . esc_url( $action ) . '" method="' . esc_attr( $method ) . '">';
        $output .= wp_nonce_field( 'theme_form_' . $action, 'theme_nonce', true, false );
        return $output;
    }
    
    /**
     * Verify form submission
     */
    public static function verify_form( $action ) {
        if ( ! isset( $_POST['theme_nonce'] ) ) {
            return false;
        }
        
        return wp_verify_nonce( $_POST['theme_nonce'], 'theme_form_' . $action );
    }
}

Preventing SQL Injection

Safe Database Queries

<?php
/**
 * NEVER do this - vulnerable to SQL injection
 */
// BAD - Direct query without escaping
$results = $wpdb->get_results( 
    "SELECT * FROM {$wpdb->posts} WHERE post_title = '{$_POST['title']}'"
);

/**
 * ALWAYS use prepared statements
 */
global $wpdb;

// GOOD - Using prepare()
$title = $_POST['title'];
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_title = %s",
        $title
    )
);

// Multiple placeholders
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} 
         WHERE post_title = %s 
         AND post_status = %s 
         AND ID > %d",
        $title,
        'publish',
        100
    )
);

// Insert with prepare
$wpdb->insert(
    $wpdb->prefix . 'my_table',
    array(
        'column1' => $value1,  // %s
        'column2' => $value2,  // %d
    ),
    array( '%s', '%d' )  // Format types
);

// Update with prepare
$wpdb->update(
    $wpdb->prefix . 'my_table',
    array( 'column1' => $new_value ),  // Data
    array( 'ID' => $id ),              // Where
    array( '%s' ),                     // Data format
    array( '%d' )                      // Where format
);

// Safe LIKE queries
$search = '%' . $wpdb->esc_like( $_POST['search'] ) . '%';
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
        $search
    )
);

// IN clause with multiple values
$ids = array( 1, 2, 3, 4, 5 );
$placeholders = array_fill( 0, count( $ids ), '%d' );
$format = implode( ', ', $placeholders );

$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->posts} WHERE ID IN ($format)",
        ...$ids
    )
);

/**
 * Using WP_Query (automatically safe)
 */
$args = array(
    'post_type' => 'post',
    'meta_key' => 'custom_field',
    'meta_value' => $_POST['value'],  // Automatically escaped
    'posts_per_page' => 10,
);
$query = new WP_Query( $args );

/**
 * Custom safe query function
 */
function get_posts_by_meta( $meta_key, $meta_value ) {
    global $wpdb;
    
    // Validate input
    $meta_key = sanitize_key( $meta_key );
    $meta_value = sanitize_text_field( $meta_value );
    
    // Prepare query
    $query = $wpdb->prepare(
        "SELECT p.* 
         FROM {$wpdb->posts} p
         INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
         WHERE pm.meta_key = %s 
         AND pm.meta_value = %s
         AND p.post_status = 'publish'",
        $meta_key,
        $meta_value
    );
    
    return $wpdb->get_results( $query );
}
Never concatenate user input directly into SQL queries. Always use $wpdb->prepare() or WordPress query functions.

File Upload and Include Security

Secure File Handling

<?php
/**
 * Secure file uploads
 */
function handle_file_upload() {
    // Check nonce
    if ( ! wp_verify_nonce( $_POST['upload_nonce'], 'file_upload' ) ) {
        wp_die( 'Security check failed' );
    }
    
    // Check capabilities
    if ( ! current_user_can( 'upload_files' ) ) {
        wp_die( 'You do not have permission to upload files' );
    }
    
    // Allowed file types
    $allowed_types = array( 'jpg', 'jpeg', 'png', 'gif', 'pdf' );
    
    // Handle upload
    if ( ! function_exists( 'wp_handle_upload' ) ) {
        require_once( ABSPATH . 'wp-admin/includes/file.php' );
    }
    
    $uploadedfile = $_FILES['file'];
    $upload_overrides = array(
        'test_form' => false,
        'mimes' => array(
            'jpg|jpeg|jpe' => 'image/jpeg',
            'gif' => 'image/gif',
            'png' => 'image/png',
            'pdf' => 'application/pdf',
        )
    );
    
    $movefile = wp_handle_upload( $uploadedfile, $upload_overrides );
    
    if ( $movefile && ! isset( $movefile['error'] ) ) {
        // File uploaded successfully
        return $movefile;
    } else {
        wp_die( $movefile['error'] );
    }
}

/**
 * Secure file includes
 */
// NEVER do this
include( $_GET['file'] ); // Vulnerable to LFI/RFI

// Safe file include
function safe_include_template( $template ) {
    // Whitelist allowed templates
    $allowed_templates = array(
        'header',
        'footer',
        'sidebar',
        'content'
    );
    
    // Validate template name
    if ( ! in_array( $template, $allowed_templates, true ) ) {
        return false;
    }
    
    // Build safe path
    $file = get_template_directory() . '/partials/' . $template . '.php';
    
    // Check file exists and is readable
    if ( file_exists( $file ) && is_readable( $file ) ) {
        include( $file );
        return true;
    }
    
    return false;
}

/**
 * Prevent direct file access
 */
// Add to top of PHP files
if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly
}

/**
 * Secure file download
 */
function secure_file_download( $file_id ) {
    // Verify nonce
    if ( ! wp_verify_nonce( $_GET['download_nonce'], 'download_' . $file_id ) ) {
        wp_die( 'Invalid download link' );
    }
    
    // Check permissions
    if ( ! current_user_can( 'read' ) ) {
        wp_die( 'You do not have permission to download this file' );
    }
    
    // Get file path from database
    $file_path = get_post_meta( $file_id, 'file_path', true );
    
    // Validate file path
    $upload_dir = wp_upload_dir();
    $base_dir = $upload_dir['basedir'];
    
    // Ensure file is within uploads directory
    $real_path = realpath( $base_dir . '/' . $file_path );
    if ( strpos( $real_path, $base_dir ) !== 0 ) {
        wp_die( 'Invalid file path' );
    }
    
    // Check file exists
    if ( ! file_exists( $real_path ) ) {
        wp_die( 'File not found' );
    }
    
    // Serve file
    header( 'Content-Type: application/octet-stream' );
    header( 'Content-Disposition: attachment; filename="' . basename( $real_path ) . '"' );
    header( 'Content-Length: ' . filesize( $real_path ) );
    readfile( $real_path );
    exit;
}

User Capability Checks

Proper Permission Verification

<?php
/**
 * Always check user capabilities before sensitive operations
 */

// Check if user can edit posts
if ( current_user_can( 'edit_posts' ) ) {
    // Show edit button
    echo '<a href="' . esc_url( $edit_link ) . '">Edit</a>';
}

// Check specific post permission
if ( current_user_can( 'edit_post', $post_id ) ) {
    // User can edit this specific post
}

// Admin-only functionality
if ( current_user_can( 'manage_options' ) ) {
    // Show admin settings
}

// Check multiple capabilities
if ( current_user_can( 'publish_posts' ) && current_user_can( 'upload_files' ) ) {
    // Show advanced editor
}

/**
 * Custom capability checks
 */
function my_theme_admin_page() {
    // Check capability
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( __( 'You do not have sufficient permissions to access this page.' ) );
    }
    
    // Display admin page
}

/**
 * AJAX capability check
 */
add_action( 'wp_ajax_delete_item', 'ajax_delete_item' );
function ajax_delete_item() {
    // Check nonce
    check_ajax_referer( 'delete_nonce', 'nonce' );
    
    // Check capability
    if ( ! current_user_can( 'delete_posts' ) ) {
        wp_send_json_error( 'Insufficient permissions' );
    }
    
    $post_id = absint( $_POST['post_id'] );
    
    // Check specific post capability
    if ( ! current_user_can( 'delete_post', $post_id ) ) {
        wp_send_json_error( 'Cannot delete this post' );
    }
    
    // Delete post
    wp_delete_post( $post_id );
    wp_send_json_success();
}

/**
 * Role-based content
 */
function display_premium_content() {
    // Check if user has specific role
    $user = wp_get_current_user();
    
    if ( in_array( 'premium_member', (array) $user->roles ) ) {
        // Show premium content
        get_template_part( 'template-parts/premium', 'content' );
    } else {
        // Show upgrade message
        get_template_part( 'template-parts/upgrade', 'message' );
    }
}

/**
 * Custom capability system
 */
class Theme_Capabilities {
    
    public static function init() {
        add_action( 'init', array( __CLASS__, 'add_capabilities' ) );
    }
    
    public static function add_capabilities() {
        $role = get_role( 'editor' );
        
        // Add custom capability
        $role->add_cap( 'edit_theme_options' );
    }
    
    public static function check( $capability ) {
        return current_user_can( $capability );
    }
}

Security Headers and Configuration

Implementing Security Headers

<?php
/**
 * Add security headers
 */
function add_security_headers() {
    // X-Frame-Options: Prevent clickjacking
    header( 'X-Frame-Options: SAMEORIGIN' );
    
    // X-Content-Type-Options: Prevent MIME sniffing
    header( 'X-Content-Type-Options: nosniff' );
    
    // X-XSS-Protection: Enable XSS filter
    header( 'X-XSS-Protection: 1; mode=block' );
    
    // Referrer-Policy
    header( 'Referrer-Policy: strict-origin-when-cross-origin' );
    
    // Content-Security-Policy
    $csp = "default-src 'self'; ";
    $csp .= "script-src 'self' 'unsafe-inline' https://ajax.googleapis.com; ";
    $csp .= "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; ";
    $csp .= "font-src 'self' https://fonts.gstatic.com; ";
    $csp .= "img-src 'self' data: https:; ";
    header( 'Content-Security-Policy: ' . $csp );
}
add_action( 'send_headers', 'add_security_headers' );

/**
 * Remove WordPress version
 */
remove_action( 'wp_head', 'wp_generator' );
add_filter( 'the_generator', '__return_empty_string' );

/**
 * Disable XML-RPC
 */
add_filter( 'xmlrpc_enabled', '__return_false' );

/**
 * Disable file editing in admin
 */
define( 'DISALLOW_FILE_EDIT', true );

/**
 * Hide login errors
 */
function hide_login_errors() {
    return 'Login failed. Please check your credentials.';
}
add_filter( 'login_errors', 'hide_login_errors' );

/**
 * Limit login attempts
 */
class Login_Security {
    private $failed_login_limit = 5;
    private $lockout_duration = 900; // 15 minutes
    
    public function __construct() {
        add_action( 'wp_login_failed', array( $this, 'login_failed' ) );
        add_filter( 'authenticate', array( $this, 'check_attempted_login' ), 30, 3 );
    }
    
    public function login_failed( $username ) {
        $ip = $_SERVER['REMOTE_ADDR'];
        $attempts = get_transient( 'login_attempts_' . $ip );
        
        if ( $attempts ) {
            $attempts++;
        } else {
            $attempts = 1;
        }
        
        set_transient( 'login_attempts_' . $ip, $attempts, $this->lockout_duration );
    }
    
    public function check_attempted_login( $user, $username, $password ) {
        $ip = $_SERVER['REMOTE_ADDR'];
        $attempts = get_transient( 'login_attempts_' . $ip );
        
        if ( $attempts >= $this->failed_login_limit ) {
            return new WP_Error( 'too_many_attempts', 
                'Too many failed login attempts. Please try again later.' );
        }
        
        return $user;
    }
}
new Login_Security();

WordPress Theme Security Checklist

  • Validate all user input
  • Sanitize data before saving to database
  • Escape all output
  • Use nonces for forms and AJAX
  • Use prepared statements for database queries
  • Check user capabilities before actions
  • Validate file uploads and types
  • Prevent direct file access
  • Implement security headers
  • Remove WordPress version numbers
  • Disable XML-RPC if not needed
  • Disable file editing in admin
  • Use HTTPS everywhere
  • Keep WordPress and plugins updated
  • Use strong passwords
  • Limit login attempts
  • Regular security audits
  • Backup regularly
  • Monitor for suspicious activity
  • Follow WordPress coding standards

Security Testing Tools

WPScan

WordPress vulnerability scanner

Sucuri SiteCheck

Free website security scanner

Theme Check Plugin

WordPress theme testing tool

Query Monitor

Developer tools panel

OWASP ZAP

Web application security scanner

Wordfence

WordPress security plugin

Best Practices

Security Development Best Practices

  • Principle of least privilege: Give minimum necessary permissions
  • Defense in depth: Multiple layers of security
  • Fail securely: Handle errors without exposing information
  • Don't trust user input: Always validate and sanitize
  • Keep it simple: Complex code is harder to secure
  • Regular updates: Keep everything up to date
  • Code reviews: Have others review security-critical code
  • Security testing: Regular penetration testing
  • Monitoring: Log and monitor suspicious activity
  • Documentation: Document security measures
Never store sensitive information like API keys, passwords, or credentials in theme files. Use wp-config.php or environment variables instead.
Use the WordPress VIP coding standards for enterprise-level security practices. They provide stricter security guidelines than standard WordPress themes.

Practice Exercise

💻
Secure Your Theme

Implement comprehensive security in your theme:

  1. Audit all user input points
  2. Add validation to all forms
  3. Implement sanitization for data storage
  4. Add escaping to all output
  5. Implement nonces for all forms
  6. Add capability checks
  7. Secure all database queries
  8. Add security headers
  9. Test with security scanner
  10. Fix all security issues found

Additional Resources