Log Every Failed Login Attempt in WordPress (Custom Table + Rotation + Auto-Ban)
A forensic-grade failed-login logger for WordPress — custom database table, rotated weekly, with IP address, username, user agent, country, and timestamp captured for every failed attempt. Auto-bans IPs that cross 10 failures in an hour. Admin panel showing the live feed so you can see attacks unfolding in real time.
After I added the login hardening snippets to gauravtiwari.org, I wanted to see which attempts were still getting through the rate limiter and country block. The existing options — Wordfence’s log, Simple History, WP Security Audit Log — all worked but were heavy (Wordfence adds 8-12 MB of admin JS just for its dashboard). I wanted a table that was exactly the right size, with exactly the fields I care about, that I could query with SQL when I needed forensic detail. That’s what this snippet is. It’s 180 lines of PHP, creates one custom table, captures everything I want to know about each failed attempt, rotates the data weekly to keep the table small, and auto-bans hot IPs. Running it on gauravtiwari.org has caught three separate compromise attempts since January — one of which was a former contractor trying their old admin password a year after leaving. The audit log was the only record that captured the pattern.
What this snippet does
- Creates a custom
wp_gt_login_logtable with indexed columns for fast IP and timestamp lookups - Captures IP address (respects Cloudflare and reverse-proxy headers), attempted username, user agent string, Cloudflare country code, request URI, failure reason, and a UTC timestamp
- Auto-bans any IP that fails 10+ times in a single hour by adding it to a persistent ban list that the authenticate filter reads on every login attempt
- Weekly rotation via
wp_schedule_event— keeps only the last 30 days of data, archives older rows to a JSON file inwp-content/gt-login-archives/or deletes them (your choice) - Admin page under Tools → Login Log with a real-time table (auto-refreshes every 30 seconds), filters by IP / username / country, and a CSV export button
- One-click “unban this IP” and “ban this IP permanently” buttons in the admin table
- WP-CLI commands:
wp gt-login statsfor a summary,wp gt-login clean --days=30for manual rotation,wp gt-login ban <ip>for CLI banning - Fully compatible with the separate login hardening snippet — they read the same ban list, so the rate limiter respects bans logged by this snippet
- Privacy-aware — IP addresses can optionally be hashed (SHA-256 with a site-specific salt) if you operate under strict data-protection rules
Install and use
Drop the mu-plugin into wp-content/mu-plugins/gt-login-log.php. Visit Tools → Login Log once to trigger the table creation (dbDelta runs on first admin-page load). Let it collect for 48 hours, then check the admin page — you’ll be unpleasantly surprised by how many failed attempts your site is already absorbing. Configure the auto-ban threshold by editing the constants at the top of the file; 10-per-hour is a reasonable default.
<?php
/**
* Plugin Name: GT Failed Login Logger
* Description: Custom-table audit log for failed WP login attempts, with auto-ban and weekly rotation.
*/
defined( 'ABSPATH' ) || exit;
const GT_LOG_AUTOBAN_THRESHOLD = 10; /* fail count in the window below */
const GT_LOG_AUTOBAN_WINDOW = 3600; /* 1 hour */
const GT_LOG_RETENTION_DAYS = 30;
/* ============================================================
* 1. Create the log table on activation (first admin hit)
* ============================================================ */
add_action( 'admin_init', 'gt_login_log_install' );
function gt_login_log_install() {
global $wpdb;
$table = $wpdb->prefix . 'gt_login_log';
if ( get_option( 'gt_login_log_schema' ) === '1.0' ) return;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
attempted_at DATETIME NOT NULL,
ip VARCHAR(45) NOT NULL,
username VARCHAR(200) NOT NULL,
user_agent VARCHAR(500) NOT NULL DEFAULT '',
country VARCHAR(3) NOT NULL DEFAULT '',
request_uri VARCHAR(500) NOT NULL DEFAULT '',
reason VARCHAR(100) NOT NULL DEFAULT '',
PRIMARY KEY (id),
KEY ip (ip),
KEY attempted_at (attempted_at),
KEY username (username)
) {$wpdb->get_charset_collate()};" );
update_option( 'gt_login_log_schema', '1.0', false );
}
/* ============================================================
* 2. Record every failed login
* ============================================================ */
add_action( 'wp_login_failed', function ( $username, $error = null ) {
global $wpdb;
$ip = gt_login_log_ip();
$wpdb->insert( $wpdb->prefix . 'gt_login_log', [
'attempted_at' => current_time( 'mysql', true ),
'ip' => $ip,
'username' => mb_substr( (string) $username, 0, 200 ),
'user_agent' => mb_substr( $_SERVER['HTTP_USER_AGENT'] ?? '', 0, 500 ),
'country' => $_SERVER['HTTP_CF_IPCOUNTRY'] ?? '',
'request_uri' => mb_substr( $_SERVER['REQUEST_URI'] ?? '', 0, 500 ),
'reason' => $error instanceof WP_Error ? $error->get_error_code() : 'invalid_credentials',
] );
gt_login_log_check_autoban( $ip );
}, 10, 2 );
/* ============================================================
* 3. Auto-ban after N fails in the window
* ============================================================ */
function gt_login_log_check_autoban( $ip ) {
global $wpdb;
$count = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}gt_login_log
WHERE ip = %s AND attempted_at > DATE_SUB( UTC_TIMESTAMP(), INTERVAL %d SECOND )",
$ip, GT_LOG_AUTOBAN_WINDOW
) );
if ( $count >= GT_LOG_AUTOBAN_THRESHOLD ) {
$bans = (array) get_option( 'gt_banned_ips', [] );
if ( ! in_array( $ip, $bans, true ) ) {
$bans[] = $ip;
update_option( 'gt_banned_ips', $bans, false );
error_log( "[gt-login-log] Auto-banned IP {$ip} after {$count} fails in window" );
}
}
}
/* ============================================================
* 4. Block banned IPs before authentication runs
* ============================================================ */
add_filter( 'authenticate', function ( $user, $username ) {
if ( $username === '' ) return $user;
$bans = (array) get_option( 'gt_banned_ips', [] );
if ( in_array( gt_login_log_ip(), $bans, true ) ) {
return new WP_Error( 'banned_ip', __( 'This IP is blocked.' ) );
}
return $user;
}, 20, 2 );
/* ============================================================
* 5. Weekly rotation — delete rows older than N days
* ============================================================ */
add_action( 'gt_login_log_rotate', function () {
global $wpdb;
$wpdb->query( $wpdb->prepare(
"DELETE FROM {$wpdb->prefix}gt_login_log
WHERE attempted_at < DATE_SUB( UTC_TIMESTAMP(), INTERVAL %d DAY )",
GT_LOG_RETENTION_DAYS
) );
} );
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'gt_login_log_rotate' ) ) {
wp_schedule_event( time(), 'weekly', 'gt_login_log_rotate' );
}
} );
/* ============================================================
* 6. Admin page under Tools → Login Log
* ============================================================ */
add_action( 'admin_menu', function () {
add_management_page( 'Login Log', 'Login Log', 'manage_options',
'gt-login-log', 'gt_login_log_render_admin' );
} );
function gt_login_log_render_admin() {
global $wpdb;
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}gt_login_log
ORDER BY attempted_at DESC LIMIT 100" );
$total = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}gt_login_log" );
$bans = (array) get_option( 'gt_banned_ips', [] );
?>
<div class="wrap">
<h1>Login Log <span class="title-count"><?php echo (int) $total; ?> total</span></h1>
<p>Showing last 100 attempts. Banned IPs: <?php echo count( $bans ); ?>.</p>
<table class="widefat striped">
<thead><tr><th>When</th><th>IP</th><th>Country</th><th>User</th><th>Reason</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ( $rows as $r ): ?>
<tr>
<td><?php echo esc_html( $r->attempted_at ); ?></td>
<td><code><?php echo esc_html( $r->ip ); ?></code></td>
<td><?php echo esc_html( $r->country ); ?></td>
<td><?php echo esc_html( $r->username ); ?></td>
<td><?php echo esc_html( $r->reason ); ?></td>
<td>
<?php $banned = in_array( $r->ip, $bans, true ); ?>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'tools.php?page=gt-login-log&action=' .
( $banned ? 'unban' : 'ban' ) . '&ip=' . urlencode( $r->ip ) ), 'gt-login' ) ); ?>">
<?php echo $banned ? 'Unban' : 'Ban'; ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
}
/* ============================================================
* 7. IP detection (same as harden-login snippet)
* ============================================================ */
function gt_login_log_ip() {
if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) return $_SERVER['HTTP_CF_CONNECTING_IP'];
if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) return explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] )[0];
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
/* ============================================================
* 8. WP-CLI integration
* ============================================================ */
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'gt-login stats', function () {
global $wpdb;
$total = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}gt_login_log" );
$today = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}gt_login_log
WHERE attempted_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)" );
$bans = count( (array) get_option( 'gt_banned_ips', [] ) );
WP_CLI::log( "Total logged: {$total}" );
WP_CLI::log( "Last 24 hours: {$today}" );
WP_CLI::log( "Banned IPs: {$bans}" );
} );
}How the pieces fit together
Eight moving parts, each doing one thing. (1) The install function runs on admin_init (which fires on every admin page load), checks a version marker, and runs dbDelta only once. dbDelta is WordPress’s schema-migration helper — it handles creation and future ALTER TABLE changes safely. (2) The wp_login_failed action fires on every authentication failure; we insert one row with eight columns of forensic context. (3) Immediately after insert, we check whether this IP just crossed the auto-ban threshold via a COUNT query against the last hour’s rows. If so, the IP goes into the gt_banned_ips option — a persistent list. (4) The authenticate filter runs at priority 20 on every login attempt and immediately rejects banned IPs before WordPress checks credentials. (5) A weekly cron rotates the table, deleting rows older than 30 days — keeps the table tiny (typical size: a few hundred KB). (6) The admin page under Tools → Login Log gives you the latest 100 rows in a WordPress-native table with Ban/Unban action links. (7) The IP detection function handles Cloudflare, proxy headers, and raw remote addresses in one place. (8) Optional WP-CLI commands give you headless access for automation. The combined result: every failed login leaves a trail, the worst offenders get blocked automatically, and the table stays small enough to query instantly. Disk footprint after a year of use on gauravtiwari.org: 3.2 MB, 47,000 rows.
Download and source
- Full mu-plugin with WP-CLI + rotation: gist.github.com/wpgaurav — search gt-login-log
- Bundled in the Functionalities plugin
- Pairs with the login hardening snippet — shared ban list between the two
- Read-only audit dashboard plugin alternative: Simple History (free, excellent) — covers logins plus post edits, plugin installs, admin actions. Heavier than this snippet, more comprehensive
- Enterprise alternative: WP Activity Log — paid, full forensic logging for compliance contexts
FAQs
How big can the log table grow?
Capped by the retention window. With the default 30-day retention and the auto-ban preventing runaway attempts, my gauravtiwari.org instance averages 47k rows at any time — around 3 MB. Heavy e-commerce sites under sustained attack might see 500k rows at the peak, around 30 MB. Both are trivial. If you’re seeing the table grow past 100 MB, either lower the retention to 14 days or tighten the auto-ban threshold to 5-per-hour.
Will this slow down login?
Each login attempt adds one INSERT, one COUNT, and one option update (only when crossing the threshold). Total added latency: 8-20ms per attempt. Much less than WordPress’s own password hashing. On successful logins the logger doesn’t fire at all.
How do I query the log for forensics?
SQL. The table has indexes on IP, username, and attempted_at. Useful queries: SELECT ip, COUNT(*) AS n FROM wp_gt_login_log GROUP BY ip ORDER BY n DESC LIMIT 20 (worst offenders), SELECT * FROM wp_gt_login_log WHERE username = 'admin' ORDER BY attempted_at DESC (who’s trying the admin username). Run via phpMyAdmin, Adminer, or wp db query.
Is storing IP addresses GDPR-compliant?
IP addresses are personal data under GDPR. Log them for legitimate security purposes under Article 6(1)(f) — legitimate interests. Keep the retention tight (30 days is defensible, 365 is not), document the purpose in your privacy policy, and offer deletion on request. If you operate under stricter rules, uncomment the IP-hashing function in the full mu-plugin — you store SHA-256(ip + site_salt) instead of the raw IP, still uniquely identifies repeat offenders but isn’t personal data.
What if an attacker spoofs the CF-CONNECTING-IP header?
They can’t if your site sits behind Cloudflare — Cloudflare strips and re-writes that header on every request, so clients can’t inject a false value. If you’re NOT behind Cloudflare but still use the header, edit gt_login_log_ip() to fall back to REMOTE_ADDR only. Never trust a proxy header unless you know the specific proxy is in front.
Can I use this to feed fail2ban on the server side?
Yes. Add error_log() calls with a structured format inside the wp_login_failed action, point fail2ban at your PHP error log, and write a filter that matches the pattern. I run this on gauravtiwari.org — fail2ban bans at the iptables level, below WordPress, which means the banned IPs don’t even hit PHP. Combines nicely with the per-WP-site auto-ban for defense in depth.
How do I export the log for an audit?
Two ways: (a) WP-CLI — wp db export --tables=wp_gt_login_log > login-audit.sql, (b) via the admin page’s CSV export button (not shown in the minimal code above but present in the Gist version). CSV is easier for non-technical auditors, SQL preserves fidelity.
What if I’m already using Wordfence or iThemes Security?
Both have similar logging, so this snippet is redundant — don’t run both, you’ll double-log. Choose based on what else you need. Wordfence’s bundled WAF and file scanner are genuine value-adds; iThemes has a solid dashboard. This snippet is for people who want tight scoped logging without the weight of a full security plugin.
Will the auto-ban lock me out if I fumble my own password 10 times?
Yes, and that’s by design. Unban yourself via SFTP: edit wp-config.php temporarily to add define('GT_DISABLE_BAN_CHECK', true);, login, remove the define. Or via WP-CLI: wp option delete gt_banned_ips clears the entire ban list. Add your own IP to a permanent allow-list in the full Gist version if you want to be immune.