🔒 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
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
SQL Injection CRITICAL
Malicious SQL queries executed on database
- Direct query manipulation
- Second-order injection
- Blind SQL injection
CSRF Attacks HIGH
Forged requests from authenticated users
- State-changing operations
- Admin actions
- Form submissions
File Inclusion HIGH
Unauthorized file access or execution
- Local file inclusion
- Remote file inclusion
- Directory traversal
Privilege Escalation MEDIUM
Unauthorized access to admin functions
- Missing capability checks
- Insecure direct references
- Broken access control
Information Disclosure LOW
Exposure of sensitive information
- Error messages
- Debug information
- Version numbers
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