Guide
Drupal failed login attempts — brute force detection on /user/login
1. Problem
You opened your Drupal site this morning and the admin dashboard was sluggish. The watchdog log scrolled past with hundreds of entries you did not write. PHP-FPM was at 90 percent. A handful of legitimate users emailed to say they got the message "Sorry, too many failed login attempts from your IP address. Try again later." — except none of them had mistyped a password.
You tail the access log and see the pattern immediately:
193.36.224.118 - - [27/Apr/2026:08:02:11 +0000] "POST /user/login HTTP/1.1" 200 7421
193.36.224.118 - - [27/Apr/2026:08:02:11 +0000] "POST /user/login HTTP/1.1" 200 7421
193.36.224.118 - - [27/Apr/2026:08:02:12 +0000] "POST /user/login?_format=json HTTP/1.1" 401 88
45.155.205.233 - - [27/Apr/2026:08:02:12 +0000] "POST /user/login HTTP/1.1" 200 7421
This is a classic drupal /user/login brute force in progress. Whoever is on the other end is cycling through usernames and passwords against your login form, and Drupal is responding with 200 for the failed-login page render plus the occasional 401 from the JSON endpoint. If you searched for "drupal failed login attempts detect" or "drupal login attack flood control" and landed here in the middle of an incident, this guide is the diagnostic playbook. Every step ties back to the auth.login_failed signal.
2. Impact
A sustained brute force against /user/login is not just noise. It costs you in three concrete ways.
- Account takeover. One weak password on user 1 and the attacker owns the site. From there: arbitrary PHP via the theme registry, malicious modules, content destruction, full database export including hashed passwords and PII.
- Self-inflicted denial of service. Drupal's flood control locks out the IP after enough failures — but it also locks out by username. An attacker who knows your editors' usernames (often leaked via author paths) can deliberately fail-login every editor out of the site. Your team cannot work; your support inbox fills up.
- Resource saturation. Each
/user/loginPOST is a full Drupal bootstrap: routing, theme negotiation, form build, form validate,hook_user_login_faileddispatch. At 50 RPS, PHP-FPM workers pin and real visitors get 502s. CDN bills go up. Page Cache hit rate craters because authenticated POSTs bypass cache.
The worst version is none of these. It is the slow, low-rate stuffing run — five attempts per minute across a hundred residential proxies — that flies under flood control for a week and eventually finds a match.
3. Why It’s Hard to Spot
Drupal core does not surface this. Watchdog (dblog) logs failed logins via \Drupal::logger('user')->notice('Login attempt failed for %user.', ...), but that entry sits in the database watchdog table in a row no one reads until something is on fire. No email. No dashboard widget. No admin notice. The site keeps responding.
Flood control is the half-measure. Drupal's user.flood.ip_limit (default 50/hour) and user.flood.user_limit (default 5/6h) eventually block the offender, but flood thresholds are calibrated for accidents, not attacks. A distributed botnet running 4 attempts per IP stays under the IP threshold forever. The user threshold catches single-username targeting, but only after the attacker burns 5 free guesses against your admin account.
Uptime checks return 200 — the login page is loading, just slowly. APM tools flag "elevated database load" without identifying the cause. Hosting support replies with "looks normal." The cron job that emails you the watchdog summary runs at 3 AM and you read it Tuesday.
By that point one of two things has happened: the attacker found a valid password, or they moved on with a confirmed list of valid usernames to try next month with a fresh credential dump.
4. Cause
The auth.login_failed signal represents one rejected authentication attempt against Drupal. It is emitted from Drupal core's user authentication path — specifically \Drupal\user\Controller\UserAuthenticationController for the JSON login endpoint, and user_login_form_submit plus \Drupal\Core\Flood\FloodInterface for the standard form login. Every emission carries the attempted username, source IP, user agent, timestamp, request path, and whether flood control was triggered.
In a healthy site, auth.login_failed is rare. A typo here, a forgotten password there. Maybe ten events a day on a site with a thousand registered users.
In a brute force, the shape of the signal stream changes in unmistakable ways:
- Volume. Tens to thousands of
auth.login_failedper minute. Healthy baseline is single digits per hour. - Username distribution. Either a single username repeated (targeted attack — the attacker knows who admin is) or a long list of common names cycled (
admin,editor,webmaster,info,test). - Source IP distribution. Either one IP hammering hard (cheap script kiddie) or a flat distribution across hundreds of IPs (botnet or proxy network — the dangerous one).
- Timing. Inhumanly regular intervals. A human types one bad password every few seconds, hesitates, retries. A script fires at 200ms intervals like a metronome.
- Companion signals. Each
auth.login_failedcorrelates with onehttp.requestto/user/loginor/user/login?_format=json. When flood control finally trips, it emits aflood.eventwith the offending identifier.
The XML-style variant is the JSON login endpoint at /user/login?_format=json, which returns 401 on failure instead of re-rendering the login form. Many security tools that watch only the form-submission path miss the JSON one entirely, even though Drupal authenticates against the same user table for both.
5. Solution
5.1 Diagnose (logs first)
Walk through these steps in order. Each names the signal it would surface in a properly instrumented system.
Step 1: Confirm the volume on the access log
# Top IPs hitting /user/login in the last 100k lines
tail -n 100000 /var/log/nginx/access.log \
| grep -E "POST /user/login(\?_format=json)?" \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
# Apache equivalent
tail -n 100000 /var/log/apache2/access.log \
| grep "POST /user/login" \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
Each matched line is one http.request event. Each one targeting /user/login would, with proper instrumentation, also produce an auth.login_failed (on bad credentials) or auth.login_success event. If a single IP is responsible for hundreds of POSTs in a few minutes, you have your answer.
Step 2: Confirm authentication failures inside Drupal
The watchdog database table is the canonical source for Drupal-internal log events. Every failed login lands here as type='user' with a "Login attempt failed" message:
drush sql:query "SELECT FROM_UNIXTIME(timestamp), hostname, message, variables \
FROM watchdog WHERE type='user' AND message LIKE 'Login attempt failed%' \
ORDER BY wid DESC LIMIT 50;"
Each row is one auth.login_failed. The variables blob (serialized PHP) contains the attempted username. If your dblog is rotated aggressively (default cap 1000 rows), bump dblog.settings.row_limit temporarily so you can see the attack pattern.
Step 3: Confirm flood control activity
Flood entries live in the flood table:
drush sql:query "SELECT event, identifier, FROM_UNIXTIME(timestamp) AS t \
FROM flood WHERE event IN ('user.failed_login_ip','user.failed_login_user') \
ORDER BY fid DESC LIMIT 50;"
Each row corresponds to a flood.event signal. The identifier column holds the IP (user.failed_login_ip) or username-IP pair (user.failed_login_user). Bursts of either confirm flood control is firing — and if it is firing repeatedly without you noticing, your monitoring is the problem, not flood control.
Step 4: Cross-check the PHP error log for password module exceptions
grep -E "user.*Login attempt failed|FloodException|user_authenticate" \
/var/log/php/error.log /var/log/php-fpm/error.log 2>/dev/null
auth.login_failed is a signal regardless of whether it came from the form path or the JSON endpoint. Volume here, cross-referenced against IPs from Step 1, confirms the brute-force shape.
Step 5: Look for username enumeration that preceded the attack
Drupal exposes user pages at /user/{uid} and author paths at /users/{username} by default. Attackers walk those URLs first to build a username list:
grep -E "GET /user/[0-9]+|GET /users/" /var/log/nginx/access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head
A burst of GETs here in the hour before POST floods is the reconnaissance phase. Those http.request events would, on a well-monitored site, surface as a username enumeration warning before the brute force ever started.
Step 6: Check whether anyone actually got in
drush sql:query "SELECT FROM_UNIXTIME(timestamp), hostname, message \
FROM watchdog WHERE type='user' AND message LIKE 'Session opened for%' \
ORDER BY wid DESC LIMIT 20;"
Each row is an auth.login_success. If any successful login arrives from an IP that produced dozens of auth.login_failed events in the preceding minutes, treat that account as compromised until proven otherwise.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
There are three root causes for the attack being possible or successful. Address them in order — most likely first.
Cause A: /user/login is publicly reachable with default flood thresholds
Signal evidence: sustained auth.login_failed volume from a small number of source IPs; flood.event entries firing but only after dozens of attempts. Each failure is one http.request POST to /user/login with a 200 (form re-render) or 401 (JSON endpoint).
Fix:
- Tighten flood control. In
settings.phpor viadrush cset:
drush cset user.flood ip_limit 20
drush cset user.flood ip_window 3600
drush cset user.flood user_limit 3
drush cset user.flood user_window 21600
- Add an edge rate limit. For nginx in front of Drupal:
limit_req_zone $binary_remote_addr zone=drupallogin:10m rate=10r/m;
location = /user/login { limit_req zone=drupallogin burst=5 nodelay; try_files $uri /index.php?$args; }
- If you do not need the JSON login endpoint, block it:
location = /user/login { if ($arg__format = "json") { return 403; } ... }.
Cause B: Username enumeration is enabled, giving the attacker a target list
Signal evidence: burst of http.request GETs to /user/{uid} and /users/{name} paths from one IP, immediately followed by auth.login_failed events for those exact usernames.
Fix:
- Install and enable Username Enumeration Prevention or its equivalent, which neutralises the timing-attack difference between "user not found" and "wrong password" responses.
- Disable public author paths if you do not need them (Pathauto pattern: remove
users/[user:name]). - Set
user.settings.registertoadmin_onlyif registration is not required.
Cause C: Weak passwords on privileged accounts and no MFA
Signal evidence: any auth.login_success from an IP that produced multiple auth.login_failed events in the preceding window. This is the only signal pattern that says "the attack worked."
Fix:
- Force a password reset on every administrator and editor:
drush sql:query "SELECT u.uid, u.name FROM users_field_data u \
INNER JOIN user__roles ur ON ur.entity_id = u.uid \
WHERE ur.roles_target_id IN ('administrator','editor');"
drush user:password "admin" "$(openssl rand -base64 24)"
- Enforce MFA via TFA for all privileged roles.
- Audit recent successful logins for any unfamiliar IP and revoke their sessions:
drush sql:query "DELETE FROM sessions WHERE uid IN (1,2,3);". - Check for files modified in the last 30 days under
sites/default/files,themes/, andmodules/— successful brute force usually leads to a webshell or malicious module upload.
5.4 Verify
The signal that should disappear is the high-volume auth.login_failed cluster, accompanied by a corresponding drop in http.request POST volume to /user/login and a return of flood.event entries to baseline (zero or near-zero).
Watch for 30 to 60 minutes after applying the fixes:
# POST volume to /user/login should drop to single digits per minute
tail -f /var/log/nginx/access.log \
| grep --line-buffered -E "POST /user/login"
# Watchdog should stop reporting new failed logins
watch -n 10 "drush sql:query \"SELECT COUNT(*) FROM watchdog \
WHERE type='user' AND message LIKE 'Login attempt failed%' \
AND timestamp > UNIX_TIMESTAMP(NOW() - INTERVAL 5 MINUTE);\""
Healthy looks like:
auth.login_failedrate drops below one per minute site-wide- No new
flood.evententries in the last 15 minutes - PHP-FPM worker count returns to baseline; database connection count falls
- The
/user/loginPOST stream contains only legitimate users with realistic timing
If after one hour you still see auth.login_failed from new IPs, the attacker has rotated source addresses. You are dealing with a distributed credential-stuffing run. Move detection to behavioural fingerprints — user agent shape, request timing variance, missing cookies — rather than IP-based blocking, and consider putting /user/login behind a WAF challenge (Cloudflare Managed Challenge, hCaptcha) for non-authenticated visitors.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as auth.login_failed.
The mechanical fixes above are not the hard part. Once you can see the attack, blocking it is twenty minutes of work. The hard part is knowing it is happening while it is still cheap to stop.
Drupal does not tell you. Watchdog does, but no one reads watchdog at 4 AM. Flood control eventually fires, but flood control is calibrated for typos, not for an adversary running 200 attempts per minute across a residential proxy network. Uptime checks see 200 OK and stay quiet. By the time a human notices, either the attacker found a password and is now sitting in your users_field_data table, or they have a confirmed username list they will return with next month against a fresh leaked credential dump.
This type of failure surfaces as auth.login_failed, the signal Logystera detects and alerts on early. The Logystera Drupal module emits one auth.login_failed per rejected authentication (form path and JSON path both), one auth.login_success per accepted one, and one flood.event whenever flood control engages. Logystera evaluates these against rule definitions like "more than 20 auth.login_failed per minute from a single IP" or "any auth.login_success preceded by 10+ auth.login_failed from the same source within 15 minutes" — and surfaces the offending IPs, target usernames, and timing in a single view, in seconds.
The detection is not magic. It is just looking at the right signal continuously, instead of letting it pile up in a database table no one tails.
7. Related Silent Failures
The following Drupal failures live in the same drupal-security signal cluster — same logs, same blind spots, often the same attackers:
- Drupal username enumeration via
/user/{uid}and/users/{name}— burst ofhttp.requestGETs to user paths, the reconnaissance phase before brute force. Detectable as a distinct signal pattern hours before the firstauth.login_failedarrives. - Drupal admin role granted unexpectedly —
user.role_changeevent right after a suspiciousauth.login_success, often the first action an attacker takes after a successful credential-stuffed login. - Drupal /user/login JSON endpoint abuse —
auth.login_failedcluster against/user/login?_format=jsonspecifically, which many legacy security modules do not watch. - Drupal flood table never clearing —
flood.evententries accumulating without expiry, indicating a misconfigured cron and a blind spot for ongoing attack volume. - Drupal session theft after compromise —
auth.login_successcontinuing to arrive from a single IP for weeks after a brute-force run, indicating the attacker captured a session cookie or persistent login token rather than re-authenticating each time.
Each surfaces as a distinct signal pattern. Each is invisible without log-driven detection. They are the same problem wearing different clothes.
See what's actually happening in your Drupal system
Connect your site. Logystera starts monitoring within minutes.