Contents
Overview
This article is a comprehensive, production-ready tutorial showing how to limit login attempts and log IP addresses in PHP for WordPress. It covers design choices, privacy considerations, multiple implementation options (transients, custom DB table), robust helper methods for fetching client IPs behind proxies, exponential backoff lockouts, admin UI basics, and recommended thresholds and deployment practices. Example code is provided as copy-paste-ready PHP and SQL snippets you can adapt into a small plugin or drop into a mu-plugin. All code examples are provided in their own language-tagged pre blocks.
Goals and threat model
- Goal: Reduce brute-force attacks against wp-login.php and XML-RPC, while recording attempts (timestamp, IP, username, result) for analysis or incident response.
- Constraints: Minimize false positives (avoid blocking legitimate shared IPs), maintain site performance, respect privacy and legal requirements (GDPR), and avoid reliance on non-PHP serverside tooling where possible.
- Threats addressed: Credential stuffing, automated brute-force bots, credential spray, repeated attacks from a small set of IPs.
Design choices
- Use WordPress hooks: wp_login_failed, authenticate, and wp_login to detect failures and success, and to prevent authentication when limits are exceeded.
- Track attempts by IP and optionally by username. Store counters using transients (fast, automatic expiry) and log raw events into a custom DB table for longer retention and auditing.
- Implement exponential backoff: small temporary lockouts first, increasing duration for repeated offenses.
- Provide options for whitelisting IPs (e.g., known admin office addresses) and blacklisting if needed.
- Be mindful of proxies and CDNs: implement a secure helper to get client IP (consider X-Forwarded-For only when you control the reverse proxy or trust it).
- Privacy: store minimal info needed, consider hashing IPs or anonymizing last octet, publish retention in privacy policy, and provide an option to purge logs.
Core implementation (single-file plugin)
Below is a practical plugin-style example you can save as wp-limit-login-and-log.php inside wp-content/mu-plugins/ or create a proper plugin folder. It:
- Creates a custom DB table on activation to store login events.
- Records every login attempt (success and failure) with IP, username, timestamp, and result.
- Limits attempts using transients keyed by IP and implements exponential backoff.
- Prevents login when the IP is over threshold and returns a safe WP_Error.
lt?php / Plugin Name: WP Limit Login Attempts amp Log IPs (Example) Description: Demonstration plugin that limits login attempts per IP and logs attempts to a custom table. Version: 1.0 Author: Example / / Configuration - adjust to suit your site / define(WPLL_MAX_ATTEMPTS, 5) // attempts before initial lockout define(WPLL_INITIAL_LOCKOUT, 15 60) // 15 minutes define(WPLL_MAX_LOCKOUT, 24 3600) // max 24 hours define(WPLL_ATTEMPT_WINDOW, 15 60) // window to count attempts (15 minutes) define(WPLL_TABLE, wpll_login_events) // table name (prefix will be applied) / Activation: create log table / register_activation_hook(__FILE__, wpll_create_table) function wpll_create_table() { global wpdb table = wpdb->prefix . WPLL_TABLE charset_collate = wpdb->get_charset_collate() sql = CREATE TABLE IF NOT EXISTS {table} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, event_time DATETIME NOT NULL, ip VARCHAR(45) NOT NULL, ip_hash CHAR(64) NOT NULL, username VARCHAR(60) DEFAULT NULL, result VARCHAR(20) NOT NULL, user_agent VARCHAR(255) DEFAULT NULL, extra TEXT DEFAULT NULL, PRIMARY KEY (id), KEY ip (ip), KEY event_time (event_time), KEY ip_hash (ip_hash) ) {charset_collate} require_once(ABSPATH . wp-admin/includes/upgrade.php) dbDelta(sql) } / Helper: get client IP securely / function wpll_get_client_ip() { // NOTE: Only trust X-Forwarded-For if you know and control reverse proxy/CDN. if (!empty(_SERVER[HTTP_X_FORWARDED_FOR])) { // Could be a comma-separated list: left-most is original client parts = explode(,, _SERVER[HTTP_X_FORWARDED_FOR]) ip = trim(reset(parts)) } elseif (!empty(_SERVER[HTTP_CLIENT_IP])) { ip = _SERVER[HTTP_CLIENT_IP] } elseif (!empty(_SERVER[REMOTE_ADDR])) { ip = _SERVER[REMOTE_ADDR] } else { ip = 0.0.0.0 } return filter_var(ip, FILTER_VALIDATE_IP) ? ip : 0.0.0.0 } / Helper: hash IP for privacy-preserving storage (HMAC with site secret) / function wpll_hash_ip(ip) { secret = defined(WPLL_IP_HASH_SECRET) ? WPLL_IP_HASH_SECRET : AUTH_KEY return hash_hmac(sha256, ip, secret) } / Log attempt to DB table / function wpll_log_attempt(ip, username, result, extra = ) { global wpdb table = wpdb->prefix . WPLL_TABLE data = array( event_time => current_time(mysql, 1), // GMT time ip => ip, ip_hash => wpll_hash_ip(ip), username => username, result => result, user_agent => isset(_SERVER[HTTP_USER_AGENT]) ? substr(_SERVER[HTTP_USER_AGENT], 0, 255) : null, extra => extra, ) wpdb->insert(table, data, array(%s,%s,%s,%s,%s,%s,%s)) } / Increase counter for IP using transients and compute lockout / function wpll_increment_attempt(ip) { transient_key = wpll_attempts_ . md5(ip) attempts = get_transient(transient_key) if (attempts === false) { // first attempt in window attempts = 1 set_transient(transient_key, attempts, WPLL_ATTEMPT_WINDOW) return array(attempts => attempts, lockout_until => 0) } attempts set_transient(transient_key, attempts, WPLL_ATTEMPT_WINDOW) if (attempts >= WPLL_MAX_ATTEMPTS) { // compute backoff multiplier // number of times threshold has been exceeded can be kept in another transient over_key = wpll_over_ . md5(ip) over = (int) get_transient(over_key) over set_transient(over_key, over, WPLL_MAX_LOCKOUT 2) // keep count longer // backoff: initial 2^(over-1), capped lockout = min(WPLL_INITIAL_LOCKOUT pow(2, max(0, over - 1)), WPLL_MAX_LOCKOUT) lockout_until = time() (int) lockout // store explicit lockout transient for quick check set_transient(wpll_lockout_ . md5(ip), lockout_until, lockout) return array(attempts => attempts, lockout_until => lockout_until) } return array(attempts => attempts, lockout_until => 0) } / Reset on successful login / add_action(wp_login, wpll_reset_attempts_on_success, 10, 2) function wpll_reset_attempts_on_success(user_login, user) { ip = wpll_get_client_ip() key = wpll_attempts_ . md5(ip) delete_transient(key) delete_transient(wpll_over_ . md5(ip)) delete_transient(wpll_lockout_ . md5(ip)) } / On failed login, increment and log / add_action(wp_login_failed, wpll_on_login_failed) function wpll_on_login_failed(username) { ip = wpll_get_client_ip() // increment attempts and potentially set lockout res = wpll_increment_attempt(ip) // log failure to DB wpll_log_attempt(ip, username, failed, json_encode(array(attempts => res[attempts]))) // Optionally: notify admin on large counts - omitted here. } / Before authenticate, block if IP is locked / add_filter(authenticate, wpll_block_if_locked, 30, 3) function wpll_block_if_locked(user, username, password) { ip = wpll_get_client_ip() lock_transient = get_transient(wpll_lockout_ . md5(ip)) if (lock_transient is_numeric(lock_transient)) { until = (int) lock_transient remaining = until - time() if (remaining > 0) { // Log the blocked attempt wpll_log_attempt(ip, username, blocked, json_encode(array(remaining => remaining))) // Return WP_Error to halt authentication minutes = ceil(remaining / 60) return new WP_Error(wpll_locked, sprintf(Too many login attempts. Try again in %d minute(s)., minutes)) } else { // expired cleanup delete_transient(wpll_lockout_ . md5(ip)) } } return user } / Optional: Add a filter to record final success/failure via wp_login_failed and wp_login (already handled), and optionally log XML-RPC or REST based attempts separately if you want coverage beyond /wp-login.php /
Notes about the code
- The code uses WordPress transients for fast counters and stores event logs into a custom table for auditing.
- IP hashing via HMAC using AUTH_KEY hides raw IPs in the hashed column raw IP is still stored in the ip column by default in this example. If you want maximum privacy, remove storing raw IP and keep only ip_hash.
- Adjust constants at top (max attempts, windows, lockout durations) to fit your threat tolerance and user base.
SQL for schema (standalone)
If you prefer running raw SQL (for non-plugin deployment), here is the create table statement. Use your sites table prefix.
CREATE TABLE wpll_login_events ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, event_time DATETIME NOT NULL, ip VARCHAR(45) NOT NULL, ip_hash CHAR(64) NOT NULL, username VARCHAR(60) DEFAULT NULL, result VARCHAR(20) NOT NULL, user_agent VARCHAR(255) DEFAULT NULL, extra TEXT DEFAULT NULL, PRIMARY KEY (id), KEY ip (ip), KEY event_time (event_time), KEY ip_hash (ip_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
Handling proxies and CDNs
If your site sits behind a reverse proxy or CDN (Cloudflare, Fastly, etc.), REMOTE_ADDR will contain the proxy IP, not the client. Consider:
- Only trusting X-Forwarded-For or CF-Connecting-IP when you control or trust the proxy/CDN. Misuse allows attackers to spoof their IP.
- Prefer server configuration to forward true client IPs (e.g., with mod_remoteip for Apache or real_ip for Nginx) and then use REMOTE_ADDR normally.
- Log both REMOTE_ADDR and the forwarded header for debugging if you are uncertain.
Alternative storage options
- Transients only: Simpler, no DB table. Good for short-term brute-force prevention but not for auditing long-term.
- Custom DB table: Best for audit logs, forensics, and analytics. Requires careful retention and indexes.
- Log to file: Append to a log file in wp-content/uploads or a secure directory, rotate logs regularly. Keep permissions strict and consider that file I/O can be slower.
- External services: Send logs to SIEM, ELK, or third-party security services for aggregation and analysis.
Admin interface (basic)
For production, add an admin screen to view logs (paginated) and to allow manual IP blocking/whitelisting. Below is a minimal snippet that adds a menu page and prints the last N rows. This is a starting point — sanitize outputs and add capabilities checks in production.
add_action(admin_menu, wpll_admin_menu) function wpll_admin_menu() { add_menu_page(Login Attempts, Login Attempts, manage_options, wpll-login-attempts, wpll_admin_page) } function wpll_admin_page() { if (!current_user_can(manage_options)) { wp_die(Insufficient privileges) } global wpdb table = wpdb->prefix . WPLL_TABLE rows = wpdb->get_results(SELECT FROM {table} ORDER BY event_time DESC LIMIT 200) echo lth2gtRecent Login Attemptslt/h2gt echo lttable class=quotwidefat fixedquotgtlttheadgtlttrgtltthgtTimelt/thgtltthgtIPlt/thgtltthgtUsernamelt/thgtltthgtResultlt/thgtltthgtUAlt/thgtlt/trgtlt/theadgtlttbodygt foreach (rows as r) { echo lttrgt echo lttdgt . esc_html(r-gtevent_time) . lt/tdgt echo lttdgt . esc_html(r-gtip) . lt/tdgt echo lttdgt . esc_html(r-gtusername) . lt/tdgt echo lttdgt . esc_html(r-gtresult) . lt/tdgt echo lttdgt . esc_html(substr(r-gtuser_agent,0,60)) . lt/tdgt echo lt/trgt } echo lt/tbodygtlt/tablegt }
Exponential backoff explained
Exponential backoff reduces brute-force efficiency by increasing lockout time each time an IP repeatedly exceeds thresholds. Typical approach:
- Allow N attempts in T minutes (e.g., 5 attempts in 15 minutes).
- On exceeding, initial lockout L (e.g., 15 minutes).
- Each subsequent exceed doubles the lockout: L times 2^(count-1), capped at a maximum (e.g., 24 hours).
Recommended thresholds and rationale
- Max attempts: 5–10 attempts per 10–20 minutes. Lower values reduce risk but may hit shared proxies.
- Initial lockout: 10–30 minutes. Gives bots pause and forces them to rotate IPs.
- Maximum lockout: 12–48 hours. Prevents indefinite blocking while still deterring repeat offenders.
- Whitelist trusted office or API IPs to prevent accidental lockouts for legitimate traffic.
Blocking by IP can penalize legitimate users behind NAT (mobile carriers, large ISPs). Recommendations:
- Combine per-IP per-username checks: if many usernames are targeted from same IP, treat differently.
- Prefer CAPTCHA or additional verification before hard lockouts for ambiguous cases.
- For sites with many shared-IP users, set higher thresholds or use progressive rate-limiting that prompts for CAPTCHA first.
Privacy, retention, and compliance
- Minimize retention: keep detailed logs only as long as needed (e.g., 90 days) and rotate/purge older entries.
- Publish logging practice in your privacy policy, including the legal basis for logging IPs.
- Consider hashing IPs in storage to reduce personal data risk. Use a site-specific secret (e.g., AUTH_KEY) for HMAC hashing.
- Ensure database exports and backups are stored securely and have appropriate retention controls.
Other countermeasures to combine
- Two-Factor Authentication (2FA) for admin accounts.
- CAPTCHA (reCAPTCHA / hCaptcha) on login form as progressive challenge.
- Block or throttle requests to xmlrpc.php or REST endpoints if abused.
- Use a web application firewall (Cloudflare, Sucuri, ModSecurity) for upstream blocking and challenge pages.
- IP reputation services to automatically block known malicious IPs.
Server-level or external blocking examples
Sometimes you want to add a server-level hard block for a specific IP. Example .htaccess deny:
ltRequireAllgt Require all granted # Block a specific IP Require not ip 203.0.113.123 lt/RequireAllgt
For Nginx, use deny 203.0.113.123 inside the server block. Server-level blocks are fast but require ops access or hosting panel.
Integration with Fail2Ban
If you maintain the server, send login attempts to a file and use Fail2Ban to block at firewall level. For WordPress, configure a custom log and a Fail2Ban filter that matches wp-login.php failure messages. This approach blocks attackers before they hit PHP repeatedly and is effective at scale.
Testing and debugging
- Enable WP_DEBUG and look for plugin errors during development.
- Test normal logins to ensure counters reset on success.
- Simulate repeated failed attempts to confirm lockouts behave as expected and exponential backoff increases lockout durations.
- Check that admin whitelist prevents accidental lockouts.
Performance considerations
- Transients are fast (object cache friendly). On sites with memcache/Redis, they’re ideal.
- Custom log tables must have proper indexes avoid heavy writes on very large sites—consider batching or external log pipelines.
- Avoid expensive queries on every page load only run checks during authentication.
Existing plugins and commercial solutions
If you prefer a maintained solution, consider well-known plugins (install and evaluate):
These handle many edge cases and offer additional protections like IP reputation and CAPTCHA integration.
Troubleshooting common issues
- If users report unexpected lockouts, check whether your site or CDN rewrites client IPs. Ensure correct detection logic for X-Forwarded-For or configure your proxy to forward the real IP safely.
- If too many events cause DB growth, implement a daily cleanup cron to purge records older than your retention period.
- If lockouts are ineffective, check priorities of your authenticate filter and ensure your code runs before WP does full authentication. The sample uses priority 30 to run after basic WP auth checks.
Maintenance tasks
- Periodic audit of blocked IPs and logs.
- Review retention policy and purge logs older than required.
- Rotate the IP hashing secret if using hashed IPs, and consider re-hashing stored values if you change the secret (with care).
Final checklist before going live
- Set reasonable thresholds for your user base.
- Whitelist trusted IPs (admins, automation) to prevent lockouts.
- Verify client IP detection with your hosting/CDN setup.
- Ensure logs are secure and retained per policy.
- Test success flow, failed flow, and lockout expiry thoroughly.
- Document the behavior in your security or admin handbook.
Further reading and resources
- WordPress hooks documentation for wp_login_failed, wp_login, and authenticate.
- GDPR and privacy guidance for logging IP addresses and retention.
- Fail2Ban and server-level firewall blocking for high-traffic sites.
Conclusion
Limiting login attempts and logging IPs is an effective, low-friction way to reduce brute-force attacks on WordPress. Use transients for fast temporary counters, a custom DB table for audit logs, and exponential backoff to dramatically slow attackers while keeping legitimate users mostly unaffected. Combine this with 2FA, CAPTCHA for suspicious cases, and a WAF or server-level protections for best results. Carefully handle client IP detection behind proxies, and implement privacy-friendly retention and hashing if required by law or policy.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |