The layers
- Rate limiting (always on, Fortify).
POST /loginis throttled to 5 attempts/minute per email + IP (FortifyServiceProvider). The 2FA challenge (5/min per pending session) and passkey login (6/min per IP) have their own limiters. - Failure counting + activity log. Every failed attempt on the dashboard
guard increments two decaying counters — per email and per source IP —
via TrackLoginShieldFailures
listening to the auth
Failedevent. The ActivityLog feature records each attempt (authcategory,login_failed, with email/IP/user-agent). A successful sign-in clears the email counter only; the IP counter is left to decay so one compromised account can't reset an attacking IP's state. - Escalating anti-bot challenge. Once either counter reaches
challenge_threshold(default 3 within a 15-minute window), the Fortify login pipeline pipe EnsureLoginShieldChallenge requires the POST to carry a valid Spam Shield payload: an HMAC-signed single-use token bound to IP + user agent, a SHA-256 proof-of-work, and a clean automation fingerprint (nonavigator.webdriver). Verified by LoginShield::verifyPayload() — deliberately WITHOUT the form shield's min-interactions / time-on-page checks, which would flag password-manager autofill. The pipe runs before the two-factor redirect so an armed bot can't use the 2FA redirect as a credential-validity oracle. Rejections re-increment the counters (blocked requests fire noFailedevent), keeping the armed state fresh during an attack. - Admin alerting. At
alert_threshold(default 10) the admin-or-higher users are emailed (LoginAttackAlertMail) — at most once per key per window — and alogin_attack_detectedentry is written to the activity log.
Client flow
The login page renders the challenge assets only when armed for the visitor
(IP counter over threshold, or the session flag flashed by a bounced POST) —
see the $loginShieldActive block in
login.blade.php.
login-shield.js arms the shared
SpamShield client (challenge fetched from /_fc/challenge under the reserved
form id -2, PoW solved in a background worker while the visitor types) and
fills the hidden shield_payload input on submit.
Worst-case human UX: a visitor targeted by email from a different IP gets one
"Additional verification is required" bounce, after which the page arms and
subsequent submits solve silently. With JavaScript disabled an armed visitor
cannot pass the challenge (a <noscript> notice says so); passkeys and admin
SSO are unaffected side doors, and the armed state decays with the window.
Configuration
Editable from Dashboard → Settings → Admin Sign-In → Login Protection
(the card writes security.login_shield_* Settings). Each Setting overrides
the matching config/cms.php → security.login_shield key, which supplies
the default and stays env-overridable:
| Setting | Config key | Env | Default |
|---|---|---|---|
security.login_shield_enabled |
enabled |
CMS_LOGIN_SHIELD |
true |
security.login_shield_challenge_threshold |
challenge_threshold |
CMS_LOGIN_SHIELD_CHALLENGE_THRESHOLD |
3 failures |
security.login_shield_alert_threshold |
alert_threshold |
CMS_LOGIN_SHIELD_ALERT_THRESHOLD |
10 failures |
security.login_shield_window_minutes |
window_minutes |
CMS_LOGIN_SHIELD_WINDOW_MINUTES |
15 |
security.login_shield_alert_email |
alert_email |
CMS_LOGIN_SHIELD_ALERT_EMAIL |
true |
Scoped to the Fortify (web) guard — feature-module logins (Memberships)
keep their own protections. Counters live in the cache, so they reset on
cache:clear; that only disarms the challenge until the next failures.
Tests: tests/Feature/Auth/LoginShieldTest.php.
Related: blocking scanner traffic
LoginShield defends the real login form against someone guessing a password.
The bulk of hostile traffic never reaches it — it's bots probing for WordPress
paths that don't exist here (/wp-login.php, /xmlrpc.php). Those are refused
at the web-server layer, with an opt-in pre-boot guard for servers that ignore
.htaccess; see
Shedding bot traffic before it costs anything.