Protecting WordPress from Credential Stuffing Attacks
Credential stuffing uses stolen credentials from data breaches to access accounts. Learn how to detect and prevent these automated attacks on your WordPress site.
Credential stuffing attacks use massive lists of stolen username/password combinations from data breaches to attempt logins across many websites. Unlike brute force attacks that guess passwords, credential stuffing uses real credentials—making them harder to detect and more likely to succeed.
How Credential Stuffing Works
- Attackers obtain stolen credentials from data breaches
- Automated tools test credentials across many sites
- Password reuse means some credentials work
- Compromised accounts are exploited or sold
Why WordPress Sites Are Targeted
- Predictable login URLs (/wp-login.php, /wp-admin)
- Large number of WordPress sites to attack
- Often have valuable user data or e-commerce
- Administrative access enables further attacks
Detection Methods
Monitor Login Patterns
// Detect credential stuffing patterns
function detect_credential_stuffing($username) {
global $wpdb;
$ip = wpfs_get_client_ip();
$window = 5 * MINUTE_IN_SECONDS;
// Count recent login attempts from this IP
$attempts = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(DISTINCT username_tried)
FROM {$wpdb->prefix}login_attempts
WHERE ip_address = %s
AND attempt_time > DATE_SUB(NOW(), INTERVAL %d SECOND)",
$ip, $window
));
// Multiple different usernames from same IP = credential stuffing
if ($attempts > 5) {
// Block this IP
block_ip_address($ip, 'Credential stuffing detected');
// Alert administrators
send_security_alert('Credential stuffing attack', array(
'ip' => $ip,
'usernames_tried' => $attempts,
'window' => '5 minutes'
));
return true;
}
// Log this attempt
$wpdb->insert(
$wpdb->prefix . 'login_attempts',
array(
'ip_address' => $ip,
'username_tried' => $username,
'attempt_time' => current_time('mysql')
)
);
return false;
}
add_action('wp_login_failed', 'detect_credential_stuffing');
Identify Bot Behavior
// Detect automated login attempts
function detect_automated_logins() {
// Check for missing or suspicious headers
$indicators = 0;
// No referer from own site
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if (empty($referer) || strpos($referer, site_url()) === false) {
$indicators++;
}
// Missing common browser headers
if (empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$indicators++;
}
// Suspicious user agent
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (empty($ua) || strlen($ua) < 20 || preg_match('/curl|wget|python|php/i', $ua)) {
$indicators++;
}
// Very fast form submission (< 2 seconds)
if (isset($_POST['login_timestamp'])) {
$elapsed = time() - intval($_POST['login_timestamp']);
if ($elapsed < 2) {
$indicators++;
}
}
return $indicators >= 2;
}
Prevention Strategies
1. Rate Limiting
// Progressive rate limiting
function progressive_rate_limit($user, $username, $password) {
$ip = wpfs_get_client_ip();
$attempts = get_failed_attempts($ip);
// Exponential backoff
$delays = array(
0 => 0, // First attempt: no delay
3 => 5, // After 3: 5 second delay
5 => 30, // After 5: 30 second delay
10 => 300, // After 10: 5 minute delay
20 => 3600 // After 20: 1 hour delay
);
$required_delay = 0;
foreach ($delays as $threshold => $delay) {
if ($attempts >= $threshold) {
$required_delay = $delay;
}
}
$last_attempt = get_last_attempt_time($ip);
$elapsed = time() - $last_attempt;
if ($elapsed < $required_delay) {
$wait = $required_delay - $elapsed;
return new WP_Error(
'rate_limited',
sprintf('Too many attempts. Please wait %d seconds.', $wait)
);
}
return $user;
}
add_filter('authenticate', 'progressive_rate_limit', 10, 3);
2. CAPTCHA After Failures
// Show CAPTCHA after failed attempts
function show_captcha_after_failures() {
$ip = wpfs_get_client_ip();
$failures = get_failed_attempts($ip);
if ($failures >= 3) {
// Render CAPTCHA
echo '';
echo '';
echo do_shortcode('[wpfs_captcha]');
echo '';
}
}
add_action('login_form', 'show_captcha_after_failures');
3. Breach Detection
// Check password against known breaches
function check_password_breach($user, $password) {
if (is_wp_error($user)) {
return $user;
}
// Hash the password for API check (k-anonymity)
$sha1 = strtoupper(sha1($password));
$prefix = substr($sha1, 0, 5);
$suffix = substr($sha1, 5);
// Check Have I Been Pwned API
$response = wp_remote_get(
"https://api.pwnedpasswords.com/range/{$prefix}",
array('timeout' => 5)
);
if (!is_wp_error($response)) {
$body = wp_remote_retrieve_body($response);
$hashes = explode("
", $body);
foreach ($hashes as $hash) {
list($hash_suffix, $count) = explode(':', trim($hash));
if ($hash_suffix === $suffix) {
// Password found in breach
flag_user_for_password_change($user->ID);
break;
}
}
}
return $user;
}
add_filter('wp_authenticate_user', 'check_password_breach', 100, 2);
4. Two-Factor Authentication
The most effective defense—even stolen credentials are useless without the second factor.
Monitoring Dashboard
// Track credential stuffing metrics
function get_stuffing_metrics() {
global $wpdb;
return array(
'unique_ips_today' => $wpdb->get_var(
"SELECT COUNT(DISTINCT ip_address)
FROM {$wpdb->prefix}login_attempts
WHERE attempt_time > DATE_SUB(NOW(), INTERVAL 1 DAY)"
),
'blocked_ips' => $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}blocked_ips
WHERE reason = 'Credential stuffing detected'"
),
'affected_users' => $wpdb->get_var(
"SELECT COUNT(DISTINCT username_tried)
FROM {$wpdb->prefix}login_attempts
WHERE success = 0 AND attempt_time > DATE_SUB(NOW(), INTERVAL 1 DAY)"
)
);
}
Conclusion
Credential stuffing is a growing threat that exploits password reuse. Implement rate limiting, CAPTCHA challenges, breach detection, and strongly encourage two-factor authentication to protect your WordPress users.
Written by Sarah Chen
WP Folder Shield Team