WordPress Under Attack: API Protection & Defense Strategies
Table of Contents
Understanding hacker tactics, attack vectors, and implementing bulletproof API security measures to protect your WordPress installation
The Reality: WordPress powers 43% of all websites, making it the #1 target for hackers worldwide. The REST API, introduced in WordPress 4.7, has become a major attack vector. In 2025 alone, over 30 million WordPress sites experienced REST API-related security incidents.
This guide reveals how attackers exploit WordPress REST API vulnerabilities and provides production-ready code to defend against these threats.
01 The WordPress Attack Landscape
Understanding how attackers target WordPress is the first step in building effective defenses. Modern attacks are sophisticated, automated, and constantly evolving.
Attack Statistics (2025)
| Attack Type | Frequency | Success Rate | Average Impact |
|---|---|---|---|
| Brute Force Login | ~40,000/day per site | 0.02% | Full compromise |
| REST API Enumeration | ~15,000/day per site | 85% | Data exposure |
| SQL Injection | ~8,000/day per site | 0.5% | Database compromise |
| XSS via API | ~5,000/day per site | 2% | User session theft |
| Plugin Exploits | ~12,000/day per site | 5% | Variable |
The most dangerous attacks combine multiple vectors. For example, attackers use REST API enumeration to discover usernames, then launch targeted brute force attacks using those usernames. This increases success rates by over 300%.
02 Common API Attack Vectors
WordPress REST API endpoints are prime targets. Here are the most exploited vulnerabilities and how to protect against them.
User Enumeration
Attackers query /wp-json/wp/v2/users to harvest usernames for credential stuffing attacks.
High Risk
Unauthenticated Access
Default endpoints expose sensitive data without requiring authentication.
Critical
Mass Assignment
Attackers manipulate request parameters to modify unintended database fields.
High Risk
Insecure Direct Object Reference
Direct access to resources by ID without proper authorization checks.
Critical
Rate Limit Bypass
Distributed attacks from multiple IPs to circumvent rate limiting.
Medium Risk
Parameter Pollution
Injecting multiple parameters with the same name to bypass validation.
High Risk
Blocking User Enumeration
The /wp-json/wp/v2/users endpoint is the #1 target for reconnaissance attacks. Here’s how to protect it:
<?php
/**
* Block user enumeration via REST API.
*
* Prevents attackers from harvesting usernames through
* the /wp-json/wp/v2/users endpoint.
*
* @since 1.0.0
*
* @param \WP_Error|null|bool $result Error if authentication failed.
* @return \WP_Error|null|bool Authentication result.
*/
function block_user_enumeration_rest_api( $result ) {
// Get current REST route.
$route = isset( $_SERVER['REQUEST_URI'] ) ?
sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_URI'] ) ) :
'';
// Check if this is a users endpoint request.
if ( false !== strpos( $route, '/wp-json/wp/v2/users' ) ) {
// Allow if user is authenticated and has proper capabilities.
if ( \is_user_logged_in() && \current_user_can( 'list_users' ) ) {
return $result;
}
// Log enumeration attempt.
error_log(
sprintf(
'User enumeration attempt blocked - IP: %s, User-Agent: %s, Time: %s',
$_SERVER['REMOTE_ADDR'] ?? 'unknown',
$_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
\current_time( 'mysql' )
)
);
// Block with 403 Forbidden.
return new \WP_Error(
'rest_user_cannot_list',
__( 'Access to user data is restricted.', 'text-domain' ),
array( 'status' => 403 )
);
}
return $result;
}
\add_filter( 'rest_authentication_errors', 'block_user_enumeration_rest_api', 99 );
/**
* Remove author archives to prevent username enumeration.
*
* Attackers can also enumerate users through author archives
* by iterating through /?author=N URLs.
*
* @since 1.0.0
*/
function block_author_enumeration_archives() {
if ( \is_author() && ! \is_user_logged_in() ) {
// Log enumeration attempt.
error_log(
sprintf(
'Author archive enumeration attempt - IP: %s, Author ID: %s',
$_SERVER['REMOTE_ADDR'] ?? 'unknown',
\get_query_var( 'author' )
)
);
// Redirect to 404.
global $wp_query;
$wp_query->set_404();
status_header( 404 );
nocache_headers();
}
}
\add_action( 'template_redirect', 'block_author_enumeration_archives' );
/**
* Disable REST API user endpoint completely.
*
* For sites that don't need the users endpoint at all.
*
* @since 1.0.0
*
* @param array $endpoints Available endpoints.
* @return array Modified endpoints.
*/
function disable_rest_api_users_endpoint( $endpoints ) {
if ( isset( $endpoints['/wp/v2/users'] ) ) {
unset( $endpoints['/wp/v2/users'] );
}
if ( isset( $endpoints['/wp/v2/users/(?P[\d]+)'] ) ) {
unset( $endpoints['/wp/v2/users/(?P[\d]+)'] );
}
return $endpoints;
}
// Uncomment to completely disable users endpoint:
// \add_filter( 'rest_endpoints', 'disable_rest_api_users_endpoint' );Completely disabling the users endpoint may break some plugins and themes. Test thoroughly in a staging environment first. The authentication-based approach (first function) is safer for most sites.
03 SQL Injection Defense
SQL injection remains one of the most devastating attack vectors. REST API endpoints that accept user input are particularly vulnerable if not properly secured.
How Attackers Exploit SQL Injection
Attackers inject malicious SQL code through API parameters. A vulnerable endpoint might look like this:
<?php
// ❌ VULNERABLE CODE - NEVER DO THIS
function vulnerable_search_endpoint( $request ) {
global $wpdb;
$search_term = $request->get_param( 'search' );
// VULNERABLE: Direct SQL query without preparation
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%{$search_term}%'"
);
return $results;
}
// Attack example:
// /wp-json/api/search?search=test' UNION SELECT user_login,user_pass FROM wp_users--Secure Implementation with Prepared Statements
<?php
/**
* Secure search endpoint with SQL injection protection.
*
* Uses prepared statements and input validation to prevent
* SQL injection attacks.
*
* @since 1.0.0
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response|\WP_Error Response or error.
*/
function secure_search_endpoint( $request ) {
global $wpdb;
// Validate and sanitize input.
$search_term = $request->get_param( 'search' );
if ( empty( $search_term ) || ! \is_string( $search_term ) ) {
return new \WP_Error(
'invalid_search_term',
__( 'Search term is required and must be a string.', 'text-domain' ),
array( 'status' => 400 )
);
}
// Sanitize the search term.
$search_term = sanitize_text_field( $search_term );
// Additional validation: max length.
if ( strlen( $search_term ) > 100 ) {
return new \WP_Error(
'search_term_too_long',
__( 'Search term cannot exceed 100 characters.', 'text-domain' ),
array( 'status' => 400 )
);
}
// Strip dangerous characters.
$search_term = preg_replace( '/[^\w\s-]/', '', $search_term );
// Use prepared statement with placeholders.
$prepared_query = $wpdb->prepare(
"SELECT ID, post_title, post_excerpt, post_date
FROM {$wpdb->posts}
WHERE post_status = %s
AND post_type = %s
AND post_title LIKE %s
LIMIT %d",
'publish',
'post',
'%' . $wpdb->esc_like( $search_term ) . '%',
50
);
// Execute query.
$results = $wpdb->get_results( $prepared_query );
if ( null === $results ) {
return new \WP_Error(
'database_error',
__( 'Database query failed.', 'text-domain' ),
array( 'status' => 500 )
);
}
// Sanitize output.
$formatted_results = array_map(
function( $post ) {
return array(
'id' => (int) $post->ID,
'title' => sanitize_text_field( $post->post_title ),
'excerpt' => sanitize_text_field( $post->post_excerpt ),
'date' => sanitize_text_field( $post->post_date ),
);
},
$results
);
return new \WP_REST_Response(
array(
'results' => $formatted_results,
'total' => count( $formatted_results ),
),
200
);
}
/**
* Register secure search endpoint.
*
* @since 1.0.0
*/
function register_secure_search_endpoint() {
register_rest_route(
'secure-api/v1',
'/search',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => 'secure_search_endpoint',
'permission_callback' => '__return_true',
'args' => array(
'search' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function( $param ) {
if ( strlen( $param ) < 2 ) {
return new \WP_Error(
'search_too_short',
__( 'Search term must be at least 2 characters.', 'text-domain' )
);
}
return true;
},
),
),
)
);
}
\add_action( 'rest_api_init', 'register_secure_search_endpoint' );Key Protection Measures
- Always use prepared statements with
$wpdb->prepare() - Use
$wpdb->esc_like()for LIKE queries to escape wildcards - Validate input types before processing
- Sanitize all user input with appropriate functions
- Use whitelisting for columns, tables, and sort orders
- Limit query results to prevent resource exhaustion
- Never trust user input even if it looks safe
04 Authentication Bypass Prevention
Attackers constantly probe for ways to bypass authentication. Improper permission callbacks are the #1 cause of authentication bypass vulnerabilities.
Common Authentication Mistakes
<?php
// ❌ MISTAKE #1: No permission callback
register_rest_route( 'api/v1', '/delete-post', array(
'methods' => 'DELETE',
'callback' => 'delete_post_handler',
// Missing permission_callback - WordPress will block this
) );
// ❌ MISTAKE #2: Always returns true
register_rest_route( 'api/v1', '/admin-action', array(
'methods' => 'POST',
'callback' => 'admin_action_handler',
'permission_callback' => '__return_true', // Anyone can access!
) );
// ❌ MISTAKE #3: Checking authentication in callback instead of permission_callback
register_rest_route( 'api/v1', '/sensitive-data', array(
'methods' => 'GET',
'callback' => function() {
if ( ! \is_user_logged_in() ) {
return new \WP_Error( 'unauthorized', 'Not logged in' );
}
// Process request...
},
'permission_callback' => '__return_true', // Wrong place for auth check!
) );
// ❌ MISTAKE #4: Client-side validation only
register_rest_route( 'api/v1', '/update-settings', array(
'methods' => 'POST',
'callback' => 'update_settings',
'permission_callback' => function( $request ) {
// Trusting client-sent header - can be spoofed!
return $request->get_header( 'X-User-Role' ) === 'admin';
},
) );Secure Authentication Implementation
<?php
/**
* Multi-layer authentication system for REST API.
*
* Implements defense in depth with multiple security checks.
*
* @since 1.0.0
*/
class REST_API_Auth_Guard {
/**
* Verify user has required capability.
*
* @param string $capability Required capability.
* @return bool|\WP_Error True if authorized, \WP_Error otherwise.
*/
public static function require_capability( $capability ) {
if ( ! \is_user_logged_in() ) {
return new \WP_Error(
'rest_unauthorized',
__( 'You must be logged in to access this endpoint.', 'text-domain' ),
array( 'status' => 401 )
);
}
if ( ! \current_user_can( $capability ) ) {
// Log unauthorized access attempt.
error_log(
sprintf(
'Unauthorized REST API access attempt - User: %d, Required Cap: %s, IP: %s',
\get_current_user_id(),
$capability,
$_SERVER['REMOTE_ADDR'] ?? 'unknown'
)
);
return new \WP_Error(
'rest_forbidden',
__( 'You do not have permission to perform this action.', 'text-domain' ),
array( 'status' => 403 )
);
}
return true;
}
/**
* Verify nonce for state-changing operations.
*
* @param \WP_REST_Request $request Request object.
* @param string $action Nonce action.
* @return bool|\WP_Error True if valid, \WP_Error otherwise.
*/
public static function verify_nonce( $request, $action ) {
$nonce = $request->get_header( 'X-WP-Nonce' );
if ( empty( $nonce ) ) {
return new \WP_Error(
'rest_missing_nonce',
__( 'Missing security token.', 'text-domain' ),
array( 'status' => 403 )
);
}
if ( ! \wp_verify_nonce( $nonce, $action ) ) {
// Log failed nonce verification.
error_log(
sprintf(
'Invalid nonce in REST request - Action: %s, User: %d, IP: %s',
$action,
\get_current_user_id(),
$_SERVER['REMOTE_ADDR'] ?? 'unknown'
)
);
return new \WP_Error(
'rest_invalid_nonce',
__( 'Security token verification failed.', 'text-domain' ),
array( 'status' => 403 )
);
}
return true;
}
/**
* Verify user owns the resource being accessed.
*
* @param int $resource_user_id User ID who owns the resource.
* @return bool|\WP_Error True if authorized, \WP_Error otherwise.
*/
public static function verify_ownership( $resource_user_id ) {
$current_user_id = \get_current_user_id();
if ( ! $current_user_id ) {
return new \WP_Error(
'rest_unauthorized',
__( 'You must be logged in.', 'text-domain' ),
array( 'status' => 401 )
);
}
// Allow if user owns resource or is admin.
if ( $current_user_id === (int) $resource_user_id || \current_user_can( 'manage_options' ) ) {
return true;
}
// Log unauthorized access.
error_log(
sprintf(
'Unauthorized resource access - User %d attempted to access resource owned by User %d',
$current_user_id,
$resource_user_id
)
);
return new \WP_Error(
'rest_forbidden',
__( 'You do not have permission to access this resource.', 'text-domain' ),
array( 'status' => 403 )
);
}
/**
* Check if request originates from allowed IP.
*
* @param array $allowed_ips Array of allowed IP addresses.
* @return bool|\WP_Error True if allowed, \WP_Error otherwise.
*/
public static function verify_ip_whitelist( $allowed_ips ) {
$client_ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ( empty( $client_ip ) ) {
return new \WP_Error(
'rest_invalid_request',
__( 'Could not determine client IP.', 'text-domain' ),
array( 'status' => 400 )
);
}
if ( ! in_array( $client_ip, $allowed_ips, true ) ) {
// Log blocked IP.
error_log(
sprintf(
'REST API access blocked - IP %s not in whitelist',
$client_ip
)
);
return new \WP_Error(
'rest_forbidden_ip',
__( 'Access denied from your IP address.', 'text-domain' ),
array( 'status' => 403 )
);
}
return true;
}
}
/**
* Example: Secure endpoint with multi-layer authentication.
*
* @since 1.0.0
*/
function register_secure_authenticated_endpoint() {
register_rest_route(
'secure-api/v1',
'/user-profile/(?P\d+)',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => 'update_user_profile_secure',
'permission_callback' => function( $request ) {
// Layer 1: Check user is logged in and has capability.
$cap_check = REST_API_Auth_Guard::require_capability( 'edit_users' );
if ( \is_wp_error( $cap_check ) ) {
return $cap_check;
}
// Layer 2: Verify ownership of resource.
$user_id = (int) $request->get_param( 'id' );
$ownership_check = REST_API_Auth_Guard::verify_ownership( $user_id );
if ( \is_wp_error( $ownership_check ) ) {
return $ownership_check;
}
// Layer 3: Verify nonce for CSRF protection.
$nonce_check = REST_API_Auth_Guard::verify_nonce( $request, 'update_user_profile' );
if ( \is_wp_error( $nonce_check ) ) {
return $nonce_check;
}
return true;
},
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => function( $param ) {
return \is_numeric( $param ) && $param > 0;
},
'sanitize_callback' => 'absint',
),
),
)
);
}
\add_action( 'rest_api_init', 'register_secure_authenticated_endpoint' );
/**
* Update user profile with full validation.
*
* @since 1.0.0
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response|\WP_Error Response or error.
*/
function update_user_profile_secure( $request ) {
$user_id = (int) $request->get_param( 'id' );
// Verify user exists.
$user = \get_user_by( 'id', $user_id );
if ( ! $user ) {
return new \WP_Error(
'user_not_found',
__( 'User not found.', 'text-domain' ),
array( 'status' => 404 )
);
}
// Process update...
$updated_data = array();
// Only update allowed fields.
$allowed_fields = array( 'first_name', 'last_name', 'description' );
foreach ( $allowed_fields as $field ) {
if ( $request->has_param( $field ) ) {
$value = sanitize_text_field( $request->get_param( $field ) );
update_user_meta( $user_id, $field, $value );
$updated_data[ $field ] = $value;
}
}
return new \WP_REST_Response(
array(
'success' => true,
'message' => __( 'Profile updated successfully.', 'text-domain' ),
'updated' => $updated_data,
),
200
);
}The multi-layer approach ensures that even if one security check fails, others will catch the attack. This is known as “defense in depth” and is essential for high-security environments.
05 Brute Force Protection
Brute force attacks attempt thousands of login combinations per minute. Without proper protection, even strong passwords can eventually be compromised.
Advanced Brute Force Defense System
<?php
/**
* Advanced brute force protection for WordPress REST API.
*
* Implements progressive delays, IP blacklisting, and CAPTCHA challenges.
*
* @since 1.0.0
*/
class Brute_Force_Protection {
/**
* Maximum login attempts before lockout.
*
* @var int
*/
private $max_attempts = 5;
/**
* Lockout duration in seconds.
*
* @var int
*/
private $lockout_duration = 1800; // 30 minutes.
/**
* Time window for counting attempts in seconds.
*
* @var int
*/
private $attempt_window = 300; // 5 minutes.
/**
* Check if IP is currently locked out.
*
* @param string $ip IP address.
* @return bool|int False if not locked, timestamp if locked.
*/
public function is_locked_out( $ip ) {
$lockout_key = 'login_lockout_' . md5( $ip );
$lockout_until = \get_transient( $lockout_key );
if ( false !== $lockout_until && time() < $lockout_until ) { return $lockout_until; } // Cleanup expired lockout. if ( false !== $lockout_until ) { delete_transient( $lockout_key ); } return false; } /** * Record failed login attempt. * * @param string $ip IP address. * @param string $username Username that was attempted. * @return bool True if locked out, false otherwise. */ public function record_failed_attempt( $ip, $username ) { $attempts_key = 'login_attempts_' . md5( $ip ); $attempts = \get_transient( $attempts_key ); if ( false === $attempts ) { $attempts = array(); } // Add current attempt with timestamp. $attempts[] = array( 'time' => time(),
'username' => sanitize_user( $username ),
);
// Remove attempts outside the time window.
$current_time = time();
$attempts = array_filter(
$attempts,
function( $attempt ) use ( $current_time ) {
return ( $current_time - $attempt['time'] ) < $this->attempt_window;
}
);
// Store updated attempts.
\set_transient( $attempts_key, $attempts, $this->attempt_window );
// Check if we should lockout.
if ( count( $attempts ) >= $this->max_attempts ) {
$this->initiate_lockout( $ip, $username, $attempts );
return true;
}
// Progressive delay based on attempt count.
$delay = min( count( $attempts ) * 2, 10 );
sleep( $delay );
return false;
}
/**
* Initiate lockout for IP address.
*
* @param string $ip IP address.
* @param string $username Last attempted username.
* @param array $attempts Array of attempt records.
*/
private function initiate_lockout( $ip, $username, $attempts ) {
$lockout_key = 'login_lockout_' . md5( $ip );
$lockout_until = time() + $this->lockout_duration;
\set_transient( $lockout_key, $lockout_until, $this->lockout_duration );
// Log the lockout.
error_log(
sprintf(
'SECURITY: Login lockout initiated - IP: %s, Username: %s, Attempts: %d',
$ip,
$username,
count( $attempts )
)
);
// Store in permanent log for analysis.
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'login_lockouts',
array(
'ip_address' => $ip,
'username' => $username,
'attempt_count' => count( $attempts ),
'lockout_until' => date( 'Y-m-d H:i:s', $lockout_until ),
'created_at' => \current_time( 'mysql' ),
),
array( '%s', '%s', '%d', '%s', '%s' )
);
// Check if this IP should be permanently blacklisted.
$this->check_for_permanent_ban( $ip );
// Send alert email for repeated lockouts.
$this->maybe_send_alert( $ip, $username );
}
/**
* Check if IP should be permanently banned.
*
* @param string $ip IP address.
*/
private function check_for_permanent_ban( $ip ) {
global $wpdb;
// Count lockouts in last 24 hours.
$lockout_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}login_lockouts
WHERE ip_address = %s
AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)",
$ip
)
);
// Permanent ban after 5 lockouts in 24 hours.
if ( $lockout_count >= 5 ) {
$banned_ips = \get_option( 'permanently_banned_ips', array() );
if ( ! in_array( $ip, $banned_ips, true ) ) {
$banned_ips[] = $ip;
update_option( 'permanently_banned_ips', $banned_ips );
error_log(
sprintf(
'SECURITY ALERT: IP permanently banned - %s (5+ lockouts in 24h)',
$ip
)
);
// Send immediate alert.
\wp_mail(
\get_option( 'admin_email' ),
'SECURITY ALERT: IP Permanently Banned',
sprintf(
"IP address %s has been permanently banned due to repeated brute force attempts.\n\n" .
"Total lockouts in 24 hours: %d\n" .
"Action required: Review logs and consider reporting to abuse contacts.",
$ip,
$lockout_count
)
);
}
}
}
/**
* Send alert email for suspicious activity.
*
* @param string $ip IP address.
* @param string $username Username attempted.
*/
private function maybe_send_alert( $ip, $username ) {
$alert_key = 'brute_force_alert_sent_' . md5( $ip );
// Only send one alert per IP per hour.
if ( false === \get_transient( $alert_key ) ) {
\wp_mail(
\get_option( 'admin_email' ),
'Brute Force Attack Detected',
sprintf(
"A brute force attack has been detected and blocked.\n\n" .
"IP Address: %s\n" .
"Targeted Username: %s\n" .
"Locked out until: %s\n\n" .
"The IP has been temporarily blocked from accessing your site.",
$ip,
$username,
date( 'Y-m-d H:i:s', time() + $this->lockout_duration )
)
);
\set_transient( $alert_key, true, HOUR_IN_SECONDS );
}
}
/**
* Clear failed attempts for IP (after successful login).
*
* @param string $ip IP address.
*/
public function clear_attempts( $ip ) {
$attempts_key = 'login_attempts_' . md5( $ip );
delete_transient( $attempts_key );
}
/**
* Check if IP is permanently banned.
*
* @param string $ip IP address.
* @return bool True if banned, false otherwise.
*/
public function is_permanently_banned( $ip ) {
$banned_ips = \get_option( 'permanently_banned_ips', array() );
return in_array( $ip, $banned_ips, true );
}
}
/**
* Integrate brute force protection with REST API authentication.
*
* @since 1.0.0
*
* @param \WP_Error|null|bool $result Authentication result.
* @return \WP_Error|null|bool Modified result.
*/
function check_brute_force_protection( $result ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ( empty( $ip ) ) {
return $result;
}
$protection = new Brute_Force_Protection();
// Check if permanently banned.
if ( $protection->is_permanently_banned( $ip ) ) {
return new \WP_Error(
'ip_banned',
__( 'Your IP address has been banned due to repeated security violations.', 'text-domain' ),
array( 'status' => 403 )
);
}
// Check if currently locked out.
$lockout_until = $protection->is_locked_out( $ip );
if ( false !== $lockout_until ) {
$remaining_time = $lockout_until - time();
return new \WP_Error(
'too_many_attempts',
sprintf(
// translators: %d: number of minutes remaining.
__( 'Too many failed login attempts. Please try again in %d minutes.', 'text-domain' ),
ceil( $remaining_time / 60 )
),
array(
'status' => 429,
'retry_after' => $remaining_time,
)
);
}
return $result;
}
\add_filter( 'rest_authentication_errors', 'check_brute_force_protection', 5 );
/**
* Record failed authentication attempts.
*
* @since 1.0.0
*
* @param \WP_Error $error Authentication error.
*/
function record_failed_auth_attempt( $error ) {
if ( ! \is_wp_error( $error ) ) {
return;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$username = isset( $_POST['username'] ) ?
sanitize_user( \wp_unslash( $_POST['username'] ) ) :
'';
if ( empty( $ip ) ) {
return;
}
$protection = new Brute_Force_Protection();
$protection->record_failed_attempt( $ip, $username );
}
\add_action( 'wp_login_failed', 'record_failed_auth_attempt' );
/**
* Clear attempts on successful login.
*
* @since 1.0.0
*
* @param string $username Username.
* @param \WP_User $user User object.
*/
function clear_attempts_on_success( $username, $user ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ( empty( $ip ) ) {
return;
}
$protection = new Brute_Force_Protection();
$protection->clear_attempts( $ip );
}
\add_action( 'wp_login', 'clear_attempts_on_success', 10, 2 );Progressive Defense:
- First failed attempt: 2-second delay
- Second attempt: 4-second delay
- Third attempt: 6-second delay
- After 5 attempts: 30-minute lockout
- After 5 lockouts in 24 hours: Permanent ban
06 DDoS & DoS Mitigation
Denial of Service attacks aim to overwhelm your server by flooding it with requests. REST API endpoints are prime targets due to their public availability.
Application-Level DoS Protection
<?php
/**
* Application-level DoS protection for REST API.
*
* Implements request rate limiting with automatic threat detection.
*
* @since 1.0.0
*/
class REST_API_DoS_Protection {
/**
* Maximum requests per minute for different threat levels.
*
* @var array
*/
private $rate_limits = array(
'normal' => 60, // 60 requests/minute.
'elevated' => 30, // 30 requests/minute.
'high' => 10, // 10 requests/minute.
'critical' => 1, // 1 request/minute.
);
/**
* Get current threat level for IP.
*
* @param string $ip IP address.
* @return string Threat level (normal, elevated, high, critical).
*/
private function get_threat_level( $ip ) {
$threat_key = 'threat_level_' . md5( $ip );
$level = \get_transient( $threat_key );
return $level ? $level : 'normal';
}
/**
* Escalate threat level for IP.
*
* @param string $ip IP address.
* @param string $reason Reason for escalation.
*/
private function escalate_threat_level( $ip, $reason ) {
$current_level = $this->get_threat_level( $ip );
$escalation_path = array(
'normal' => 'elevated',
'elevated' => 'high',
'high' => 'critical',
'critical' => 'critical',
);
$new_level = $escalation_path[ $current_level ];
// Store new threat level (30 minute duration).
$threat_key = 'threat_level_' . md5( $ip );
\set_transient( $threat_key, $new_level, 30 * MINUTE_IN_SECONDS );
error_log(
sprintf(
'Threat level escalated for IP %s: %s → %s (Reason: %s)',
$ip,
$current_level,
$new_level,
$reason
)
);
// Alert on critical escalation.
if ( 'critical' === $new_level && 'critical' !== $current_level ) {
\wp_mail(
\get_option( 'admin_email' ),
'CRITICAL: DoS Attack Detected',
sprintf(
"A potential DoS attack has been detected from IP: %s\n\n" .
"The IP has been rate-limited to 1 request per minute.\n\n" .
"Reason: %s",
$ip,
$reason
)
);
}
}
/**
* Check request rate limit.
*
* @param string $ip IP address.
* @return bool|\WP_Error True if allowed, \WP_Error if rate limited.
*/
public function check_rate_limit( $ip ) {
$threat_level = $this->get_threat_level( $ip );
$max_requests = $this->rate_limits[ $threat_level ];
$requests_key = 'api_requests_' . md5( $ip );
$requests = \get_transient( $requests_key );
if ( false === $requests ) {
$requests = array();
}
$current_time = time();
// Remove requests older than 1 minute.
$requests = array_filter(
$requests,
function( $timestamp ) use ( $current_time ) {
return ( $current_time - $timestamp ) < 60; } ); // Check if limit exceeded. if ( count( $requests ) >= $max_requests ) {
// Escalate threat level.
$this->escalate_threat_level( $ip, 'Rate limit exceeded' );
return new \WP_Error(
'rate_limit_exceeded',
sprintf(
// translators: %d: number of requests allowed.
__( 'Rate limit exceeded. Maximum %d requests per minute allowed.', 'text-domain' ),
$max_requests
),
array(
'status' => 429,
'retry_after' => 60,
)
);
}
// Add current request.
$requests[] = $current_time;
\set_transient( $requests_key, $requests, 60 );
// Analyze request pattern for anomalies.
$this->analyze_request_pattern( $ip, $requests );
return true;
}
/**
* Analyze request pattern for suspicious behavior.
*
* @param string $ip IP address.
* @param array $requests Array of request timestamps.
*/
private function analyze_request_pattern( $ip, $requests ) {
if ( count( $requests ) < 10 ) {
return;
}
// Check for uniform timing (bot signature).
$intervals = array();
for ( $i = 1; $i < count( $requests ); $i++ ) {
$intervals[] = $requests[ $i ] - $requests[ $i - 1 ];
}
$avg_interval = array_sum( $intervals ) / count( $intervals );
$variance = 0;
foreach ( $intervals as $interval ) {
$variance += pow( $interval - $avg_interval, 2 );
}
$variance = $variance / count( $intervals );
$std_dev = sqrt( $variance );
// If intervals are too uniform (low variance), likely a bot.
if ( $std_dev < 0.5 && count( $requests ) > 20 ) {
$this->escalate_threat_level( $ip, 'Bot-like request pattern detected' );
}
// Check for burst pattern.
$recent_requests = array_filter(
$requests,
function( $timestamp ) {
return ( time() - $timestamp ) < 10; // Last 10 seconds. } ); if ( count( $recent_requests ) > 15 ) {
$this->escalate_threat_level( $ip, 'Request burst detected' );
}
}
/**
* Check for expensive endpoint access.
*
* @param string $endpoint Endpoint being accessed.
* @param string $ip IP address.
* @return bool|\WP_Error True if allowed, \WP_Error if blocked.
*/
public function check_expensive_endpoint( $endpoint, $ip ) {
$expensive_endpoints = array(
'/wp/v2/search',
'/wp/v2/users',
'/custom/export',
);
$is_expensive = false;
foreach ( $expensive_endpoints as $pattern ) {
if ( false !== strpos( $endpoint, $pattern ) ) {
$is_expensive = true;
break;
}
}
if ( ! $is_expensive ) {
return true;
}
// Stricter limits for expensive endpoints.
$expensive_key = 'expensive_requests_' . md5( $ip );
$expensive_count = (int) \get_transient( $expensive_key );
if ( $expensive_count >= 5 ) {
$this->escalate_threat_level( $ip, 'Excessive expensive endpoint access' );
return new \WP_Error(
'expensive_endpoint_limit',
__( 'Rate limit exceeded for this endpoint. Please try again later.', 'text-domain' ),
array( 'status' => 429 )
);
}
\set_transient( $expensive_key, $expensive_count + 1, 60 );
return true;
}
}
/**
* Apply DoS protection to REST API requests.
*
* @since 1.0.0
*
* @param mixed $result Response to replace the requested version with.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request used to generate the response.
* @return mixed Modified result.
*/
function apply_dos_protection( $result, $server, $request ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ( empty( $ip ) ) {
return $result;
}
$protection = new REST_API_DoS_Protection();
// Check general rate limit.
$rate_check = $protection->check_rate_limit( $ip );
if ( \is_wp_error( $rate_check ) ) {
return $rate_check;
}
// Check expensive endpoint limit.
$endpoint = $request->get_route();
$expensive_check = $protection->check_expensive_endpoint( $endpoint, $ip );
if ( \is_wp_error( $expensive_check ) ) {
return $expensive_check;
}
return $result;
}
\add_filter( 'rest_pre_dispatch', 'apply_dos_protection', 10, 3 );Advanced DoS Detection
The system automatically detects attack patterns:
- Uniform Timing: Requests at exact intervals indicate automated bots
- Request Bursts: 15+ requests in 10 seconds triggers escalation
- Expensive Endpoints: Stricter limits on resource-intensive operations
- Progressive Throttling: Threat level escalates with continued abuse
07 Privilege Escalation Defense
Privilege escalation attacks attempt to gain higher access levels than authorized. This often exploits flaws in permission checking logic.
<?php
/**
* Prevent privilege escalation in user role updates.
*
* Attackers often try to escalate their role to administrator
* through API endpoints that update user data.
*
* @since 1.0.0
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response|\WP_Error Response or error.
*/
function secure_user_role_update( $request ) {
$user_id = (int) $request->get_param( 'id' );
$new_role = $request->get_param( 'role' );
// Verify user exists.
$user = \get_user_by( 'id', $user_id );
if ( ! $user ) {
return new \WP_Error(
'user_not_found',
__( 'User not found.', 'text-domain' ),
array( 'status' => 404 )
);
}
$current_user_id = \get_current_user_id();
// Prevent self-escalation.
if ( $user_id === $current_user_id && $new_role ) {
error_log(
sprintf(
'SECURITY: Self-escalation attempt blocked - User %d tried to change own role to %s',
$current_user_id,
$new_role
)
);
return new \WP_Error(
'cannot_self_escalate',
__( 'You cannot change your own role.', 'text-domain' ),
array( 'status' => 403 )
);
}
// Validate new role.
if ( $new_role ) {
$allowed_roles = array( 'subscriber', 'contributor', 'author' );
// Only administrators can assign editor/admin roles.
if ( \current_user_can( 'manage_options' ) ) {
$allowed_roles[] = 'editor';
$allowed_roles[] = 'administrator';
}
if ( ! in_array( $new_role, $allowed_roles, true ) ) {
error_log(
sprintf(
'SECURITY: Privilege escalation attempt - User %d tried to assign role %s',
$current_user_id,
$new_role
)
);
return new \WP_Error(
'invalid_role',
__( 'You do not have permission to assign this role.', 'text-domain' ),
array( 'status' => 403 )
);
}
// Prevent escalation above current user's level.
$current_user = \wp_get_current_user();
$current_user_role = $current_user->roles[0] ?? '';
$role_hierarchy = array(
'subscriber' => 1,
'contributor' => 2,
'author' => 3,
'editor' => 4,
'administrator' => 5,
);
$current_level = $role_hierarchy[ $current_user_role ] ?? 0;
$target_level = $role_hierarchy[ $new_role ] ?? 0;
if ( $target_level > $current_level ) {
error_log(
sprintf(
'SECURITY: Privilege escalation blocked - User %d (%s) attempted to assign %s role',
$current_user_id,
$current_user_role,
$new_role
)
);
return new \WP_Error(
'insufficient_permissions',
__( 'You cannot assign a role higher than your own.', 'text-domain' ),
array( 'status' => 403 )
);
}
// Update role.
$user->set_role( $new_role );
// Log role change.
error_log(
sprintf(
'User role changed - Target User: %d, New Role: %s, Changed By: %d',
$user_id,
$new_role,
$current_user_id
)
);
}
return new \WP_REST_Response(
array(
'success' => true,
'message' => __( 'User updated successfully.', 'text-domain' ),
),
200
);
}
/**
* Prevent capability manipulation.
*
* Attackers may try to add capabilities directly to bypass role restrictions.
*
* @since 1.0.0
*
* @param array $data User data being updated.
* @param bool $update Whether this is an update operation.
* @param int $user_id User ID being updated.
* @return array Modified data.
*/
function prevent_capability_manipulation( $data, $update, $user_id ) {
// Only check updates (not new user creation).
if ( ! $update ) {
return $data;
}
// Check if request is from REST API.
if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
return $data;
}
// Verify user cannot manipulate capabilities directly.
if ( isset( $_POST['capabilities'] ) || isset( $_POST['caps'] ) ) {
error_log(
sprintf(
'SECURITY: Direct capability manipulation blocked - User %d attempted to modify capabilities for User %d',
\get_current_user_id(),
$user_id
)
);
// Remove capability data from request.
unset( $_POST['capabilities'] );
unset( $_POST['caps'] );
}
return $data;
}
\add_filter( 'wp_pre_insert_user_data', 'prevent_capability_manipulation', 10, 3 );08 Information Disclosure Prevention
Information disclosure vulnerabilities leak sensitive data to unauthorized users. REST API responses often expose more data than intended.
<?php
/**
* Filter sensitive data from REST API responses.
*
* Prevents accidental exposure of emails, IPs, and other PII.
*
* @since 1.0.0
*
* @param \WP_REST_Response $response Response object.
* @param \WP_Post $post Post object.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response Modified response.
*/
function filter_sensitive_post_data( $response, $post, $request ) {
$data = $response->get_data();
// Remove author email unless user has permission.
if ( isset( $data['author'] ) && ! \current_user_can( 'edit_users' ) ) {
$author = \get_user_by( 'id', $data['author'] );
if ( $author ) {
// Only expose safe author data.
$safe_author_data = array(
'id' => $author->ID,
'name' => $author->display_name,
'slug' => $author->user_nicename,
);
// Remove email and other sensitive fields.
unset( $data['author_email'] );
unset( $data['author_ip'] );
}
}
// Remove internal meta fields.
if ( isset( $data['meta'] ) ) {
$allowed_meta = apply_filters( 'rest_api_allowed_meta_keys', array() );
$filtered_meta = array();
foreach ( $data['meta'] as $key => $value ) {
if ( in_array( $key, $allowed_meta, true ) ) {
$filtered_meta[ $key ] = $value;
}
}
$data['meta'] = $filtered_meta;
}
$response->set_data( $data );
return $response;
}
\add_filter( 'rest_prepare_post', 'filter_sensitive_post_data', 10, 3 );
/**
* Disable WordPress version exposure in REST API.
*
* @since 1.0.0
*/
function remove_version_from_rest_api() {
remove_action( 'rest_api_init', 'rest_send_cors_headers' );
\add_filter(
'rest_index',
function( $response ) {
unset( $response->data['namespaces'] );
unset( $response->data['routes'] );
return $response;
}
);
}
\add_action( 'rest_api_init', 'remove_version_from_rest_api' );
/**
* Prevent error message information disclosure.
*
* @since 1.0.0
*
* @param mixed $result Response data.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request object.
* @return mixed Modified result.
*/
function sanitize_rest_api_errors( $result, $server, $request ) {
if ( ! \is_wp_error( $result ) ) {
return $result;
}
// In production, don't expose detailed error messages.
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
return $result;
}
// Generic error message for security issues.
$security_codes = array(
'rest_forbidden',
'rest_unauthorized',
'rest_cannot_read',
'rest_cannot_edit',
'rest_cannot_delete',
);
if ( in_array( $result->get_error_code(), $security_codes, true ) ) {
return new \WP_Error(
'access_denied',
__( 'Access denied.', 'text-domain' ),
array( 'status' => 403 )
);
}
// Generic error for database/server errors.
$internal_codes = array(
'db_insert_error',
'db_update_error',
'db_delete_error',
);
if ( in_array( $result->get_error_code(), $internal_codes, true ) ) {
return new \WP_Error(
'internal_error',
__( 'An error occurred. Please try again.', 'text-domain' ),
array( 'status' => 500 )
);
}
return $result;
}
\add_filter( 'rest_post_dispatch', 'sanitize_rest_api_errors', 10, 3 );09 Incident Detection & Response
Early detection is critical. Implement comprehensive logging and automated alerts to catch attacks in progress.
<?php
/**
* Security incident detection and response system.
*
* Monitors for attack patterns and automatically responds to threats.
*
* @since 1.0.0
*/
class Security_Incident_Responder {
/**
* Detect and log security incidents.
*
* @param \WP_REST_Request $request Request object.
* @param mixed $response Response data.
*/
public function monitor_request( $request, $response ) {
$incident_detected = false;
$incident_type = '';
$severity = 'low';
// Check for SQL injection attempts.
$params = $request->get_params();
foreach ( $params as $key => $value ) {
if ( \is_string( $value ) && $this->contains_sql_injection( $value ) ) {
$incident_detected = true;
$incident_type = 'sql_injection_attempt';
$severity = 'critical';
break;
}
}
// Check for XSS attempts.
if ( ! $incident_detected ) {
foreach ( $params as $key => $value ) {
if ( \is_string( $value ) && $this->contains_xss( $value ) ) {
$incident_detected = true;
$incident_type = 'xss_attempt';
$severity = 'high';
break;
}
}
}
// Check for path traversal.
if ( ! $incident_detected ) {
foreach ( $params as $key => $value ) {
if ( \is_string( $value ) && $this->contains_path_traversal( $value ) ) {
$incident_detected = true;
$incident_type = 'path_traversal_attempt';
$severity = 'high';
break;
}
}
}
// Check for authentication bypass attempts.
if ( \is_wp_error( $response ) ) {
$error_code = $response->get_error_code();
if ( in_array( $error_code, array( 'rest_forbidden', 'rest_unauthorized' ), true ) ) {
$incident_detected = true;
$incident_type = 'unauthorized_access_attempt';
$severity = 'medium';
}
}
if ( $incident_detected ) {
$this->log_incident( $incident_type, $severity, $request );
$this->respond_to_incident( $incident_type, $severity );
}
}
/**
* Check if string contains SQL injection patterns.
*
* @param string $value Value to check.
* @return bool True if suspicious pattern detected.
*/
private function contains_sql_injection( $value ) {
$sql_patterns = array(
'/(\bUNION\b.*\bSELECT\b)/i',
'/(\bSELECT\b.*\bFROM\b.*\bWHERE\b)/i',
'/(DROP|DELETE|INSERT|UPDATE)\s+(TABLE|DATABASE)/i',
'/(\bOR\b\s+\d+\s*=\s*\d+)/i',
'/(\bAND\b\s+\d+\s*=\s*\d+)/i',
'/(--|;|\/\*|\*\/|xp_|sp_)/i',
);
foreach ( $sql_patterns as $pattern ) {
if ( preg_match( $pattern, $value ) ) {
return true;
}
}
return false;
}
/**
* Check if string contains XSS patterns.
*
* @param string $value Value to check.
* @return bool True if suspicious pattern detected.
*/
private function contains_xss( $value ) {
$xss_patterns = array(
'/<script[^>]*>.*<\/script>/i',
'/javascript:/i',
'/on\w+\s*=/i',
'/<iframe/i',
'/<object/i',
'/<embed/i', ); foreach ( $xss_patterns as $pattern ) { if ( preg_match( $pattern, $value ) ) { return true; } } return false; } /** * Check if string contains path traversal patterns. * * @param string $value Value to check. * @return bool True if suspicious pattern detected. */ private function contains_path_traversal( $value ) { $traversal_patterns = array( '/\.\.\//', '/\.\.\\\\/', '/%2e%2e%2f/i', '/%2e%2e\//i', ); foreach ( $traversal_patterns as $pattern ) { if ( preg_match( $pattern, $value ) ) { return true; } } return false; } /** * Log security incident. * * @param string $type Incident type. * @param string $severity Severity level. * @param \WP_REST_Request $request Request object. */ private function log_incident( $type, $severity, $request ) { global $wpdb; $incident_data = array( 'incident_type' => $type,
'severity' => $severity,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
'user_id' => \get_current_user_id(),
'endpoint' => $request->get_route(),
'method' => $request->get_method(),
'params' => \wp_json_encode( $request->get_params() ),
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'created_at' => \current_time( 'mysql' ),
);
$wpdb->insert(
$wpdb->prefix . 'security_incidents',
$incident_data,
array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' )
);
error_log(
sprintf(
'SECURITY INCIDENT: %s (Severity: %s) - IP: %s, Endpoint: %s',
$type,
$severity,
$incident_data['ip_address'],
$incident_data['endpoint']
)
);
}
/**
* Respond to security incident.
*
* @param string $type Incident type.
* @param string $severity Severity level.
*/
private function respond_to_incident( $type, $severity ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
// Immediate response for critical incidents.
if ( 'critical' === $severity ) {
// Temporary ban for 1 hour.
$ban_key = 'security_ban_' . md5( $ip );
\set_transient( $ban_key, true, HOUR_IN_SECONDS );
// Send immediate alert.
\wp_mail(
\get_option( 'admin_email' ),
'CRITICAL SECURITY INCIDENT',
sprintf(
"A critical security incident has been detected:\n\n" .
"Type: %s\n" .
"IP Address: %s\n" .
"Action Taken: IP banned for 1 hour\n\n" .
"Please review security logs immediately.",
$type,
$ip
)
);
}
// Alert for high severity.
if ( 'high' === $severity ) {
// Rate limit email alerts.
$alert_key = 'security_alert_sent_' . md5( $type . $ip );
if ( false === \get_transient( $alert_key ) ) {
\wp_mail(
\get_option( 'admin_email' ),
'Security Alert: ' . ucwords( str_replace( '_', ' ', $type ) ),
sprintf(
"A high-severity security incident has been detected:\n\n" .
"Type: %s\n" .
"IP Address: %s\n\n" .
"Please review security logs.",
$type,
$ip
)
);
\set_transient( $alert_key, true, HOUR_IN_SECONDS );
}
}
}
}
/**
* Initialize incident response system.
*/
function init_incident_responder() {
$responder = new Security_Incident_Responder();
\add_filter(
'rest_post_dispatch',
function( $response, $server, $request ) use ( $responder ) {
$responder->monitor_request( $request, $response );
return $response;
},
999,
3
);
}
\add_action( 'rest_api_init', 'init_incident_responder' );10 Complete Hardening Checklist
Use this comprehensive checklist to secure your WordPress REST API:
- ✓ Block user enumeration via
/wp-json/wp/v2/users - ✓ Implement rate limiting on all endpoints
- ✓ Add permission callbacks to ALL custom endpoints
- ✓ Enable HTTPS and force SSL
- ✓ Update WordPress, plugins, and themes
- ✓ Change default admin username
- ✓ Implement brute force protection
- ✓ Implement request logging and monitoring
- ✓ Configure CORS properly (no wildcards)
- ✓ Add input validation to all endpoints
- ✓ Enable 2FA for admin accounts
- ✓ Review and disable unused endpoints
- ✓ Implement DoS protection
- ✓ Set up security alerts
- ✓ Implement JWT or OAuth authentication
- ✓ Add request signing (HMAC)
- ✓ Create incident response procedures
- ✓ Conduct security audit
- ✓ Implement IP whitelisting for admin
- ✓ Set up automated backups
- ✓ Review plugin security
| Security Measure | Effectiveness | Difficulty | Performance Impact |
|---|---|---|---|
| User Enumeration Block | Very High | Easy | None |
| Rate Limiting | High | Medium | Low |
| Brute Force Protection | Very High | Medium | Low |
| SQL Injection Prevention | Critical | Medium | None |
| DoS Protection | High | Hard | Medium |
| Incident Monitoring | High | Medium | Low |
Final Recommendations
WordPress security is an ongoing process, not a one-time fix. Attackers constantly evolve their tactics, and new vulnerabilities are discovered regularly.
- Layer your defenses – No single security measure is perfect
- Monitor everything – You can’t defend against what you can’t see
- Fail securely – When in doubt, deny access
- Update regularly – 76% of attacks exploit known vulnerabilities
- Test your security – Regular audits catch issues before attackers do
- Have a plan – Know what to do when (not if) you’re attacked
Emergency Response
If you discover an active attack:
- Isolate immediately – Take the site offline if necessary
- Preserve evidence – Don’t delete logs or files
- Change all credentials – Database, admin, FTP, hosting
- Scan for malware – Use multiple scanning tools
- Restore from backup – If you have a clean backup
- Document everything – For insurance and legal purposes
- Get professional help – Contact a security expert