10 Lesser-Known WordPress Security Practices
Table of Contents
While most WordPress developers know to sanitize inputs and use prepared statements, there’s a whole world of security vulnerabilities that rarely get discussed. This guide explores 10 advanced security practices with production-ready code following WordPress Coding Standards (WPCS).
#1 Timing Attack Prevention in Password Comparisons
Most developers use simple string comparison (===) when validating tokens or passwords. This creates a timing attack vulnerability where attackers can measure response times to deduce the correct value character by character.
The Vulnerability
Standard comparison operators exit early when they find a mismatch, creating measurable time differences:
<?php
// ❌ VULNERABLE: Early exit creates timing leak
function verify_api_token( $provided_token ) {
$stored_token = get_option( 'api_token' );
// Timing attack: comparison stops at first wrong character
if ( $provided_token === $stored_token ) {
return true;
}
return false;
}Solution
Use PHP’s hash_equals() function, which performs constant-time comparison:
<?php
/**
* Verify API token using constant-time comparison.
*
* Prevents timing attacks by ensuring comparison takes
* the same amount of time regardless of where strings differ.
*
* @since 1.0.0
*
* @param string $provided_token Token from request.
* @return bool True if token is valid.
*/
function verify_api_token( $provided_token ) {
$stored_token = get_option( 'api_token' );
// Validate input types
if ( ! is_string( $provided_token ) || ! is_string( $stored_token ) ) {
return false;
}
// Use constant-time comparison to prevent timing attacks
return hash_equals( $stored_token, $provided_token );
}hash_equals() for comparing any security-sensitive strings: API keys, tokens, nonces, password hashes, HMAC signatures, or session identifiers.
#2 CSV Injection in Data Exports
When exporting user data to CSV files, formulas starting with =, +, -, or @ can be executed by Excel or Google Sheets, leading to arbitrary code execution on the user’s machine.
The Attack Vector
An attacker submits a form with a value like =cmd|'/c calc'!A1. When exported to CSV and opened in Excel, this executes the calculator application.
Protection
<?php
/**
* Sanitize CSV field to prevent injection attacks.
*
* Escapes formulas and special characters that could be
* executed when CSV is opened in spreadsheet applications.
*
* @since 1.0.0
*
* @param string $field The CSV field value.
* @return string Sanitized field value.
*/
function sanitize_csv_field( $field ) {
$field = (string) $field;
// Characters that trigger formula execution
$dangerous_chars = array( '=', '+', '-', '@', "\t", "\r" );
// Check if field starts with dangerous character
if ( in_array( substr( $field, 0, 1 ), $dangerous_chars, true ) ) {
// Prepend single quote to neutralize formula
$field = "'" . $field;
}
// Escape double quotes and wrap in quotes
$field = '"' . str_replace( '"', '""', $field ) . '"';
return $field;
}
/**
* Export user data to CSV with injection protection.
*
* @since 1.0.0
*
* @param array $users Array of user data.
* @return void Outputs CSV file.
*/
function export_users_to_csv( $users ) {
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=users.csv' );
$output = fopen( 'php://output', 'w' );
// Output headers
fputcsv( $output, array( 'Name', 'Email', 'Bio' ) );
foreach ( $users as $user ) {
$row = array(
sanitize_csv_field( $user['name'] ),
sanitize_csv_field( $user['email'] ),
sanitize_csv_field( $user['bio'] ),
);
fputcsv( $output, $row );
}
fclose( $output );
exit;
}#3 Object Injection via Unserialize
Using unserialize() on untrusted data can lead to arbitrary code execution through PHP object injection attacks. This is one of the most dangerous vulnerabilities in WordPress plugins.
How It Works
When PHP unserializes data, it can instantiate arbitrary objects and trigger magic methods like __wakeup(), __destruct(), or __toString(), which may execute dangerous code.
<?php
// ❌ VULNERABLE: Never unserialize user input
$data = unserialize( $_COOKIE['user_prefs'] );Solution
<?php
/**
* Safely deserialize data with type validation.
*
* Uses JSON instead of serialize/unserialize to prevent
* object injection attacks. JSON only supports basic types.
*
* @since 1.0.0
*
* @param string $serialized_data Serialized data string.
* @param array $allowed_types Allowed data types (default: array, object).
* @return mixed|false Unserialized data or false on failure.
*/
function safe_unserialize( $serialized_data, $allowed_types = array( 'array', 'object' ) ) {
// Validate input
if ( ! is_string( $serialized_data ) || empty( $serialized_data ) ) {
return false;
}
// Decode JSON instead of unserialize
$data = json_decode( $serialized_data, true );
// Validate JSON parsing
if ( JSON_ERROR_NONE !== json_last_error() ) {
error_log( 'JSON decode error: ' . json_last_error_msg() );
return false;
}
// Type validation
$data_type = gettype( $data );
if ( ! in_array( $data_type, $allowed_types, true ) ) {
error_log( 'Invalid data type: ' . $data_type );
return false;
}
return $data;
}
/**
* Store user preferences safely.
*
* @since 1.0.0
*
* @param array $preferences User preferences array.
* @return bool True on success.
*/
function store_user_preferences( $preferences ) {
// Use JSON encoding instead of serialize
$json_data = wp_json_encode( $preferences );
if ( false === $json_data ) {
return false;
}
// Store in cookie with secure flags
setcookie(
'user_prefs',
$json_data,
array(
'expires' => time() + YEAR_IN_SECONDS,
'path' => COOKIEPATH,
'domain' => COOKIE_DOMAIN,
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Strict',
)
);
return true;
}unserialize(), use the allowed_classes option introduced in PHP 7.0: unserialize($data, ['allowed_classes' => false])
#4 Nonce Reuse Vulnerabilities
WordPress nonces are valid for 12-24 hours by default, which creates a window for replay attacks. For sensitive operations, you need single-use tokens.
The Problem
Standard WordPress nonces can be reused multiple times within their validity period:
<?php
// ❌ VULNERABLE: Nonce can be reused for 12-24 hours
if ( wp_verify_nonce( $_POST['_wpnonce'], 'delete_account' ) ) {
delete_user_account();
}Single-Use Token Implementation
<?php
/**
* Generate single-use token for sensitive operations.
*
* Creates a token that is invalidated immediately after use.
* Tokens expire after 5 minutes if not used.
*
* @since 1.0.0
*
* @param string $action Action name for token.
* @param int $user_id User ID (default: current user).
* @return string Single-use token.
*/
function generate_single_use_token( $action, $user_id = 0 ) {
if ( 0 === $user_id ) {
$user_id = get_current_user_id();
}
// Generate cryptographically secure random token
$token = bin2hex( random_bytes( 32 ) );
// Store token in transient with 5-minute expiration
$transient_key = 'sut_' . md5( $user_id . $action . $token );
set_transient(
$transient_key,
array(
'user_id' => $user_id,
'action' => $action,
'created' => time(),
),
5 * MINUTE_IN_SECONDS
);
return $token;
}
/**
* Verify and consume single-use token.
*
* Token is deleted after verification, preventing reuse.
*
* @since 1.0.0
*
* @param string $token Token to verify.
* @param string $action Expected action name.
* @param int $user_id User ID (default: current user).
* @return bool True if valid, false otherwise.
*/
function verify_single_use_token( $token, $action, $user_id = 0 ) {
if ( 0 === $user_id ) {
$user_id = get_current_user_id();
}
// Validate token format
if ( ! is_string( $token ) || 64 !== strlen( $token ) ) {
return false;
}
$transient_key = 'sut_' . md5( $user_id . $action . $token );
$stored_data = get_transient( $transient_key );
// Token doesn't exist or expired
if ( false === $stored_data ) {
return false;
}
// Verify action and user match
if ( $stored_data['action'] !== $action || $stored_data['user_id'] !== $user_id ) {
return false;
}
// Delete token immediately (single-use)
delete_transient( $transient_key );
return true;
}
// Usage example
function delete_account_handler() {
$token = sanitize_text_field( wp_unslash( $_POST['token'] ?? '' ) );
if ( ! verify_single_use_token( $token, 'delete_account' ) ) {
wp_die( 'Invalid or expired token' );
}
// Token verified and consumed - safe to proceed
delete_user_account();
}#5 Path Traversal in Template Loading
Dynamically loading templates based on user input without proper validation can allow attackers to access arbitrary files on the server through path traversal attacks.
Template Loader
<?php
/**
* Safely load template file with path traversal protection.
*
* Validates template name against whitelist and prevents
* directory traversal attempts.
*
* @since 1.0.0
*
* @param string $template_name Template name (without extension).
* @param array $data Data to pass to template.
* @return bool True if loaded successfully.
*/
function load_template_safely( $template_name, $data = array() ) {
// Whitelist of allowed templates
$allowed_templates = array(
'user-profile',
'dashboard',
'settings',
'notifications',
);
// Validate template name is in whitelist
if ( ! in_array( $template_name, $allowed_templates, true ) ) {
error_log( 'Attempted to load invalid template: ' . $template_name );
return false;
}
// Remove any directory traversal attempts
$template_name = basename( $template_name );
$template_name = str_replace( array( '.', '/', '\\' ), '', $template_name );
// Construct safe path
$template_dir = get_template_directory() . '/templates/';
$template_path = $template_dir . $template_name . '.php';
// Verify resolved path is within template directory
$real_template_path = realpath( $template_path );
$real_template_dir = realpath( $template_dir );
if ( false === $real_template_path || 0 !== strpos( $real_template_path, $real_template_dir ) ) {
error_log( 'Path traversal attempt detected' );
return false;
}
// Check file exists and is readable
if ( ! file_exists( $real_template_path ) || ! is_readable( $real_template_path ) ) {
return false;
}
// Extract data for template use
if ( ! empty( $data ) && is_array( $data ) ) {
extract( $data, EXTR_SKIP );
}
// Load template
include $real_template_path;
return true;
}#6 Subdomain Takeover Risks
When DNS records point to external services that no longer exist, attackers can claim those services and take control of your subdomain. This is especially common with CDNs, hosting platforms, and SaaS services.
DNS Monitoring
<?php
/**
* Check for potential subdomain takeover vulnerabilities.
*
* Monitors DNS records for dangling references to external services.
*
* @since 1.0.0
*
* @param string $subdomain Subdomain to check.
* @return array Status and vulnerability details.
*/
function check_subdomain_takeover_risk( $subdomain ) {
// Vulnerable CNAME patterns (services that allow takeover)
$vulnerable_patterns = array(
'azure-api.net' => 'Azure',
'azurewebsites.net' => 'Azure Web Apps',
'cloudfront.net' => 'Amazon CloudFront',
'herokuapp.com' => 'Heroku',
'github.io' => 'GitHub Pages',
's3.amazonaws.com' => 'Amazon S3',
'wordpress.com' => 'WordPress.com',
'pantheonsite.io' => 'Pantheon',
'surge.sh' => 'Surge',
'bitbucket.io' => 'Bitbucket',
);
// Get DNS records
$dns_records = dns_get_record( $subdomain, DNS_CNAME );
if ( false === $dns_records || empty( $dns_records ) ) {
return array(
'status' => 'safe',
'message' => 'No CNAME records found',
);
}
// Check each CNAME
foreach ( $dns_records as $record ) {
$target = $record['target'] ?? '';
foreach ( $vulnerable_patterns as $pattern => $service ) {
if ( false !== strpos( $target, $pattern ) ) {
// Check if target resolves
$ip = gethostbyname( $target );
if ( $ip === $target ) {
// DNS doesn't resolve - potential takeover risk
return array(
'status' => 'vulnerable',
'service' => $service,
'target' => $target,
'message' => sprintf(
'Dangling CNAME to %s detected. Immediate action required!',
$service
),
);
}
}
}
}
return array(
'status' => 'safe',
'message' => 'No takeover vulnerabilities detected',
);
}
/**
* Schedule automated subdomain monitoring.
*
* @since 1.0.0
*/
function schedule_subdomain_monitoring() {
if ( ! wp_next_scheduled( 'check_subdomain_security' ) ) {
wp_schedule_event( time(), 'daily', 'check_subdomain_security' );
}
}
add_action( 'init', 'schedule_subdomain_monitoring' );
add_action( 'check_subdomain_security', function() {
$subdomains = array( 'blog', 'cdn', 'api', 'assets' );
foreach ( $subdomains as $subdomain ) {
$domain = $subdomain . '.' . parse_url( home_url(), PHP_URL_HOST );
$result = check_subdomain_takeover_risk( $domain );
if ( 'vulnerable' === $result['status'] ) {
// Send alert to admin
wp_mail(
get_option( 'admin_email' ),
'CRITICAL: Subdomain Takeover Risk Detected',
$result['message']
);
}
}
} );#7 Application-Level DoS Prevention
Rate limiting at the web server level doesn’t protect against resource-intensive operations. Implement application-level throttling for expensive operations.
Rate Limiter
<?php
/**
* Implement sliding window rate limiter.
*
* Prevents application-level DoS by limiting expensive operations
* per user/IP using a sliding window algorithm.
*
* @since 1.0.0
*
* @param string $action Action identifier.
* @param int $limit Maximum requests per window.
* @param int $window Time window in seconds.
* @param string $identifier Unique identifier (default: IP address).
* @return bool True if allowed, false if rate limited.
*/
function check_rate_limit( $action, $limit = 10, $window = 60, $identifier = null ) {
if ( null === $identifier ) {
$identifier = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
$cache_key = 'rate_limit_' . md5( $action . $identifier );
$current_time = time();
// Get existing request timestamps
$requests = get_transient( $cache_key );
if ( false === $requests ) {
$requests = array();
}
// Remove requests outside the sliding window
$requests = array_filter(
$requests,
function( $timestamp ) use ( $current_time, $window ) {
return $timestamp > ( $current_time - $window );
}
);
// Check if limit exceeded
if ( count( $requests ) >= $limit ) {
// Log rate limit violation
error_log(
sprintf(
'Rate limit exceeded for %s by %s',
$action,
$identifier
)
);
return false;
}
// Add current request
$requests[] = $current_time;
// Store updated requests
set_transient( $cache_key, $requests, $window );
return true;
}
/**
* Handle expensive search operation with rate limiting.
*
* @since 1.0.0
*/
function handle_search_request() {
// Limit to 5 searches per minute per IP
if ( ! check_rate_limit( 'complex_search', 5, 60 ) ) {
wp_send_json_error(
array(
'message' => 'Rate limit exceeded. Please try again in a minute.',
),
429
);
}
// Proceed with expensive search operation
$results = perform_complex_search();
wp_send_json_success( $results );
}#8 Database Collation Attacks
MySQL’s default utf8mb4_unicode_ci collation is case-insensitive, which can lead to authentication bypass when comparing sensitive strings.
The Vulnerability
In case-insensitive collations, admin and ADMIN are treated as identical, potentially allowing attackers to bypass uniqueness constraints or authentication checks.
Binary Comparison
<?php
/**
* Verify username with binary comparison.
*
* Uses BINARY comparison to prevent collation-based attacks.
*
* @since 1.0.0
*
* @global wpdb $wpdb WordPress database abstraction object.
*
* @param string $username Username to verify.
* @return bool True if username exists (case-sensitive).
*/
function verify_username_case_sensitive( $username ) {
global $wpdb;
// Use BINARY for case-sensitive comparison
$query = $wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->users} WHERE BINARY user_login = %s",
$username
);
$count = (int) $wpdb->get_var( $query );
return $count > 0;
}
/**
* Check if API key exists with binary collation.
*
* @since 1.0.0
*
* @global wpdb $wpdb WordPress database abstraction object.
*
* @param string $api_key API key to verify.
* @return int|false User ID if valid, false otherwise.
*/
function get_user_by_api_key( $api_key ) {
global $wpdb;
// Binary comparison prevents case-insensitive bypass
$query = $wpdb->prepare(
"
SELECT user_id
FROM {$wpdb->prefix}user_api_keys
WHERE BINARY api_key = %s
AND expires_at > NOW()
LIMIT 1
",
$api_key
);
$user_id = $wpdb->get_var( $query );
return $user_id ? (int) $user_id : false;
}VARBINARY or add BINARY to comparisons. Never rely on collation for security.
#9 Race Conditions in File Uploads
Between file upload validation and storage, attackers can exploit TOCTOU (Time-of-Check-Time-of-Use) race conditions to upload malicious files.
Atomic Upload Handler
<?php
/**
* Handle file upload with race condition protection.
*
* Uses atomic operations to prevent TOCTOU vulnerabilities.
*
* @since 1.0.0
*
* @param array $file Uploaded file data from $_FILES.
* @return array|WP_Error Upload result or error.
*/
function handle_secure_file_upload( $file ) {
// Allowed MIME types
$allowed_types = array(
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
);
// Validate file was uploaded via HTTP POST
if ( ! isset( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
return new WP_Error( 'invalid_upload', 'Invalid file upload' );
}
// Generate random temporary filename to prevent predictability
$temp_name = wp_unique_filename(
sys_get_temp_dir(),
bin2hex( random_bytes( 16 ) ) . '.tmp'
);
$temp_path = sys_get_temp_dir() . '/' . $temp_name;
// Move uploaded file atomically
if ( ! move_uploaded_file( $file['tmp_name'], $temp_path ) ) {
return new WP_Error( 'move_failed', 'Failed to move uploaded file' );
}
// Now validate the moved file (prevents TOCTOU)
$file_type = wp_check_filetype( $temp_path );
$mime_type = mime_content_type( $temp_path );
// Validate MIME type
if ( ! in_array( $mime_type, $allowed_types, true ) ) {
unlink( $temp_path );
return new WP_Error( 'invalid_type', 'File type not allowed' );
}
// Validate file size
$max_size = 5 * MB_IN_BYTES;
if ( filesize( $temp_path ) > $max_size ) {
unlink( $temp_path );
return new WP_Error( 'file_too_large', 'File exceeds maximum size' );
}
// For images, re-encode to strip potential exploits
if ( 0 === strpos( $mime_type, 'image/' ) ) {
$image_editor = wp_get_image_editor( $temp_path );
if ( ! is_wp_error( $image_editor ) ) {
$saved = $image_editor->save( $temp_path );
if ( is_wp_error( $saved ) ) {
unlink( $temp_path );
return $saved;
}
}
}
// Generate secure final filename
$upload_dir = wp_upload_dir();
$final_name = wp_unique_filename( $upload_dir['path'], sanitize_file_name( $file['name'] ) );
$final_path = $upload_dir['path'] . '/' . $final_name;
// Move to final location with strict permissions
if ( ! rename( $temp_path, $final_path ) ) {
unlink( $temp_path );
return new WP_Error( 'save_failed', 'Failed to save file' );
}
// Set restrictive permissions (owner read/write only)
chmod( $final_path, 0600 );
return array(
'path' => $final_path,
'url' => $upload_dir['url'] . '/' . $final_name,
'type' => $mime_type,
);
}#10 Advanced Security Headers
Beyond basic headers like HSTS and CSP, modern browsers support powerful isolation features through COOP, COEP, and Permissions-Policy.
Security Headers
<?php
/**
* Set advanced security headers.
*
* Implements modern browser security features including
* cross-origin isolation and permissions policy.
*
* @since 1.0.0
*/
function set_advanced_security_headers() {
// Cross-Origin Opener Policy (COOP)
// Isolates browsing context from cross-origin windows
header( 'Cross-Origin-Opener-Policy: same-origin' );
// Cross-Origin Embedder Policy (COEP)
// Prevents loading cross-origin resources without CORS
header( 'Cross-Origin-Embedder-Policy: require-corp' );
// Cross-Origin Resource Policy (CORP)
// Controls whether resource can be loaded cross-origin
header( 'Cross-Origin-Resource-Policy: same-site' );
// Permissions Policy (formerly Feature Policy)
// Disable dangerous browser features
$permissions = array(
'geolocation' => '()', // Disable geolocation
'microphone' => '()', // Disable microphone
'camera' => '()', // Disable camera
'payment' => '()', // Disable payment API
'usb' => '()', // Disable USB
'magnetometer' => '()', // Disable sensors
'accelerometer' => '()',
'gyroscope' => '()',
'interest-cohort' => '()', // Disable FLoC
'sync-xhr' => '()', // Disable synchronous XHR
);
$permissions_string = implode(
', ',
array_map(
function( $feature, $allowlist ) {
return $feature . '=' . $allowlist;
},
array_keys( $permissions ),
array_values( $permissions )
)
);
header( 'Permissions-Policy: ' . $permissions_string );
// Referrer Policy
// Limit referrer information leakage
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
// X-Content-Type-Options
// Prevent MIME type sniffing
header( 'X-Content-Type-Options: nosniff' );
// Strict Transport Security (HSTS)
// Force HTTPS for 2 years, include subdomains
if ( is_ssl() ) {
header( 'Strict-Transport-Security: max-age=63072000; includeSubDomains; preload' );
}
// Content Security Policy with strict nonce-based policy
$nonce = base64_encode( random_bytes( 16 ) );
$csp_directives = array(
"default-src 'self'",
"script-src 'self' 'nonce-{$nonce}' 'strict-dynamic'",
"style-src 'self' 'nonce-{$nonce}'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
);
header( 'Content-Security-Policy: ' . implode( '; ', $csp_directives ) );
// Store nonce for inline scripts
global $wp_scripts;
if ( ! isset( $wp_scripts->csp_nonce ) ) {
$wp_scripts->csp_nonce = $nonce;
}
}
add_action( 'send_headers', 'set_advanced_security_headers' );Conclusion
These advanced security techniques go beyond the basics and protect against sophisticated attack vectors that many developers overlook. By implementing timing-safe comparisons, preventing CSV injection, avoiding object deserialization vulnerabilities, using single-use tokens, validating paths properly, monitoring DNS, rate-limiting expensive operations, using binary collations, preventing race conditions, and implementing modern security headers, you’ll significantly harden your WordPress application against advanced threats.
Remember: Security is a continuous process, not a one-time implementation. Regularly audit your code, keep dependencies updated, monitor for new vulnerabilities, and always follow the principle of defense in depth.