Brute-force protection for both sign-in forms: the dashboard login
(POST /login) and the member login (/members/login). Staff and members
share one guard and one users table, so the two forms are two doors into the
same accounts, and they share one set of counters. Normal sign-ins never see
any of it — password-manager autofill stays friction-free.
Why nothing here uses the IP address
Behind a CDN without trusted proxies, the app reads every visitor as the CDN's address, and a distributed attack rotates through thousands of addresses anyway. An IP-keyed limit therefore either collapses into one bucket shared by every real visitor or hands each attacking address a fresh budget. So no decision here keys on the visitor's IP: the protection is keyed on the account under attack and on the site as a whole. The IP is still recorded in the Activity Log, for information.
The layers
LoginShield owns all of it. Both
forms run each attempt through LoginShield::guarded() — the dashboard through
the Fortify pipe EnforceLoginShield,
the member form from its own component — so the two can't drift.
- The account, not the typed text. The typed address is looked up through
the user provider and counts as that account only when it matches the
stored address exactly (ignoring case and surrounding spaces). MySQL's
accent-insensitive collations also match lookalike spellings (
jäne@findsjane@). Such a variant is treated like an address with no account: it is counted under its own spelling and refused as bad credentials without its password ever being checked, so it can neither sign in nor hand an attacker a fresh counter. Addresses with no account are counted and locked exactly like real ones, so the lockout can't reveal which accounts exist. - Per-account lockout — always on. Failed attempts from browsers that
haven't signed in to the account are counted per account inside the window
(default 15 minutes). At the lockout threshold (default 10), or the daily
one (default 50 in 24 hours), the account refuses sign-ins from new browsers
outright, before any password is checked, with a message saying when to try
again and suggesting a password reset. This replaced Fortify's 5-per-minute
email + IP limiter (the named
loginlimiter is nowLimit::none()), and it stays on when the challenge is switched off. - Known browsers. Every completed sign-in — password, two-factor, passkey,
admin SSO, member social sign-in, registration, checkout — remembers the
browser for that account in the
wpcms_devicecookie (KnownDevices; HttpOnly, SameSite=Lax, 180 days, encrypted, up to 5 accounts per browser). Each entry carries an HMAC over the account id and its current password hash, so it can't be forged or moved to another account, and changing the password forgets every browser at once. A known browser skips the lockout and the challenge, with a budget of its own (5 failures per window, 15 per day), so an attacker can lock an account against everyone except its owner, and a stolen cookie — or a shared machine the owner once used — is worth a handful of guesses. - Escalating anti-bot challenge. Once an account reaches the challenge
threshold (default 3), or the whole site reaches the site-wide threshold
(default 20) inside the window, every new-browser attempt must carry a valid
Spam Shield payload: an HMAC-signed single-use token, a SHA-256
proof-of-work, and a clean automation fingerprint (no
navigator.webdriver), verified byLoginShield::verifyPayload()— deliberately without the form shield's time-on-page and interaction checks, which would flag autofill.- Login tokens are bound to the user agent but not the IP (behind a CDN
the address can change between fetching a token and signing in); form
tokens are bound to the visitor's session.
/_fc/challenge(thespam-shield-challengelimiter) throttles every challenge per session — but only a session the browser brought back; a cookieless client gets a fresh session per request, so those share one site-wide limit. - A rejected challenge counts toward the site-wide counter only. It tested no password, and an owner whose browser fumbled the challenge must not lock themselves out.
- The checks run before the two-factor redirect, so neither a locked account nor an armed bot can use that redirect as a password oracle.
- Login tokens are bound to the user agent but not the IP (behind a CDN
the address can change between fetching a token and signing in); form
tokens are bound to the visitor's session.
- Admin alerting. When an account reaches the alert threshold (default
10) — or its lockout, if that comes first — and when failures across the
site reach the site-wide threshold, the admin users are emailed
(LoginAttackAlertMail): at most
once per day for each account, and once per day for the site. Alerts follow
the alert-email setting only, so switching the challenge off doesn't
silence them. Each alert writes
login_attack_detectedto the Activity Log (key_typeemailorsite); a locked account also writeslogin_account_locked(once per window — including when a lowered threshold locks it without any new failure). - One attempt per account at a time. A short cache lock covers each attempt from the lockout check through the password check and the counting. The counters live in the cache, whose file store increments without a lock, so a parallel burst would otherwise all read the count before any of it was written. A second simultaneous attempt for the same account is refused as "in progress" (and counted site-wide). A known browser takes a lock of its own, so a stream of attacker attempts holding the account's lock can never stall its owner.
Failures are counted from the auth Failed event alone
(TrackLoginShieldFailures).
The member form checks the password with Auth::validate() — so a two-factor
account gets no session before its second factor — which fires no event, so the
form fires Failed itself. That also puts member failures in the Activity Log
and the client report.
What clears an account
A successful sign-in clears nothing. A remember-me cookie fires a Login
event on every request the owner makes, even on static pages, so clearing there
would let the owner's own browsing reset an attacker's budget. An account is
cleared by:
- Any password change — a hook on the User model (members included), so no path is missed: the owner's settings page, an admin setting it on the Users screen, a reset, admin recovery. Whatever an attacker was guessing is moot. When the owner made the change, their own browser is re-remembered (the new password hash retired every remembered browser); an admin's browser is never remembered for someone else's account.
- A password reset (the dashboard's, the member form's, or accepting a
staff invite — all fire
PasswordReset) also remembers the browser that did it, since the reset proved control of the mailbox; so does the flag-file/_admin-reset. - Unlock sign-in — on Dashboard → Users, a locked account shows a red
Locked to new browsers until {time} badge (site timezone) and its row menu
offers Unlock sign-in; a member's edit page (Memberships → Members)
shows the same as a callout with the button. Both are admin-only and hold to
the Users screen's tier rule server-side: an Admin can't unlock a Super.
Each unlock is logged (
login_account_unlocked, with who did it). php artisan cms:unlock-login {email}— for support over SSH.
Clearing an account also forgets its once-per-window "locked" log marker and its once-per-day alert key. Without that, an account re-locked later the same day would go unlogged and unalerted, because both still read "already sent".
The ways back in: reset requests, sign-ups, passkeys, invites
A locked account is only as safe as the routes around it that need no
password. GuestAuthLimits bounds
the reset-request and sign-up forms — the dashboard's (POST /forgot-password,
POST /register, through
ThrottleGuestAuthWrites)
and the member forms (/members/forgot-password, /members/register) — the
same way, and never on the IP. There is deliberately no site-wide cap on
any of them: a hard cap would be a free lever to block every reset and every
paid sign-up. Three bounds instead:
- Per browser session, 10 attempts per 10 minutes on each form. A nuisance bound only — a script mints sessions for free — so a refusal says "Too many attempts" in place without revealing anything about the address.
- Per account, 5 reset mails per hour, so the reset form can't be pointed at one inbox. The cap is checked before the broker mints a token: refusing inside the send would still replace the token and kill the link the owner already has. Over the cap the request is answered exactly like a sent one.
- The Shield challenge, required on both forms while the login shield is
armed site-wide, and on a form alone once it has seen more attempts in the
window than real visitors produce (
surface_threshold, default 50, config only). Each attempt then costs a solved proof-of-work and an unautomated browser, which is what holds against a bot. The refusal is worded for the page ("request the reset link again", "try signing up again"). The forms render the challenge markup only while armed; the member forms, which submit over Livewire, always load the script and arm mid-visit like the member login.
Only misses count toward the site-wide counter: a reset request for an address with no account, a refused limit, a failed challenge. A sent reset link or an ordinary sign-up never does, so a launch-day rush can't fire a "login attack" alert or arm the login form.
The dashboard reset reply never reveals an account. Both of Fortify's
reset-link responses are bound to one
PasswordResetLinkRequestedResponse:
a sent link, an address with no account, a broker-throttled request and a
capped one all say "If an account exists for that email, a reset link is on its
way." The mail itself — staff and member alike — is sent after the response
(User::sendPasswordResetNotification, defer()), so the time the reply takes
says nothing either. The member form has always answered this way.
Passkeys, invites and the admin reset keep their own named limiters, off
the bare throttle:N,M bucket that search, consent and form tokens share per
IP (five page views used to cost a member their passkey sign-in; the page-view
beacon has since moved to a bucket of its own):
| Routes | Limiter | Keyed on |
|---|---|---|
| Fortify passkey ceremonies | passkeys |
the signed-in user, else the session the browser brought back (6/min); a client that drops its cookie shares one sessionless bucket (30/min) |
/members/passkeys/* |
member-passkeys |
the same shape |
/set-password (invite acceptance) |
user-invite |
the session alone (10/min) — the token is 48 random characters, so there is nothing to guess |
/_admin-reset |
admin-reset |
the session alone — it has no secret to guess |
None of them has a site-wide bucket. Anyone can fill one, and then every
passkey sign-in, or every invitee, on the site is refused. The sessionless
bucket holds only scripts: the sign-in page sets the cookie before a passkey
request, and the ceremony keeps its challenge in the session, so a client
without the cookie can't finish one. Invites can't use it, because the
invitee's first request comes from their mail with no cookie.
LoginShield::lockedUntil($user) reports when an account's lock against new
browsers lifts (from whichever of its window and day counters holds it), for
the Users screen.
Tests: EscapeRoutesWithoutIpTest, AuthHardeningTest.
Client flow
The dashboard login renders the challenge assets only when the challenge is
armed for the visitor: site-wide, or by the session flag a bounced attempt
flashes (see $loginShieldActive in
login.blade.php).
Per-account state isn't knowable before an email is typed, so the first
new-browser attempt on an armed account is bounced once ("Additional
verification is required"), and the page then arms.
The member login always loads login-shield.js. It submits over Livewire, so the form can arm mid-visit, and a script that only arrives in a Livewire update never runs. The script stays inert until the form is marked armed, arms on demand at submit, and takes a fresh token after every attempt, because the server spends one per attempt.
Either way the script arms the shared SpamShield client (the challenge is
fetched from /_fc/challenge under the reserved form id -2, and the
proof-of-work is solved in a background worker while the visitor types) and
fills in the payload on submit. With JavaScript disabled an armed visitor
cannot pass the challenge (a <noscript> notice says so); a known browser,
passkeys and admin SSO are unaffected, 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 — the challenge only; the lockout is always on, and alerts follow alert_email |
security.login_shield_challenge_threshold |
challenge_threshold |
CMS_LOGIN_SHIELD_CHALLENGE_THRESHOLD |
3 failures per account |
security.login_shield_lockout_threshold |
lockout_threshold |
CMS_LOGIN_SHIELD_LOCKOUT_THRESHOLD |
10 failures per account |
| — (config only) | daily_lockout_threshold |
CMS_LOGIN_SHIELD_DAILY_LOCKOUT_THRESHOLD |
50 failures per account in 24 hours (never below the lockout) |
security.login_shield_site_threshold |
site_threshold |
CMS_LOGIN_SHIELD_SITE_THRESHOLD |
20 failures across the site |
security.login_shield_alert_threshold |
alert_threshold |
CMS_LOGIN_SHIELD_ALERT_THRESHOLD |
10 failures per account |
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 |
The card refuses a challenge threshold above the lockout, which could never
arm. The counters live in the cache, so they reset on cache:clear.
Passwords
Member passwords — sign-up, reset, and accounts an admin creates — use
MemberPassword:
eight characters and, in production, a check against the Have I Been Pwned
breach corpus, which is what stops credential stuffing (reused, already-leaked
passwords). Staff passwords, including any an admin sets on the Users screen,
use the staff policy (Password::defaults(): 12 characters, mixed case,
numbers and symbols, breach-checked in production). The breach check
(BreachedPasswordVerifier)
goes through OutboundHttp, sends only a 5-character hash prefix, gives up
after 5 seconds, and lets the password through when the API is unreachable.
Breached passwords found at sign-in
A password is checked when it is set, so one chosen before the check existed, or leaked since, was never looked at again. So every successful sign-in also checks the password it used (SignInBreachCheck):
- Both sign-in forms. The dashboard login runs it from a Fortify pipe
(
CheckPasswordBreach, placed beforeEnforceLoginShieldso it wraps the whole attempt and only sees a proved password, including one that stops at the two-factor challenge). The member login runs it insignIn()right afterAuth::validate()succeeds. A wrong password is never checked. - After the response (
defer()), so the API's latency never reaches the sign-in, and at most once per account per password per 30 days. - Only a real answer counts. The verifier's
lookup()returns true, false, or null when the API couldn't be asked. An outage is retried an hour later, never remembered as "clean". - A hit sets
users.password_breached_at, but only while the stored hash is still the one that signed in (one conditionalUPDATE), so a check that lands after a password change never flags the new password. - A notice, never a block. The staff dashboard shows Your password appeared in a known data breach with a Change password button (to Settings → Password); the member dashboard shows the same with a link to its Change password card. Nothing redirects or forces a change (owner's decision, 2026-10-07). Any password change clears the flag (the User model's updating hook).
- Production only, like the
uncompromised()rules, and switchable withconfig('cms.security.breach_check_on_login')(envCMS_BREACH_CHECK_ON_LOGIN, default on).
Members change their own password on the member dashboard's Change
password card: current password, then a new one under MemberPassword
(breach-checked in production). Every signed-in account can open the member
area, so a staff account there (Staff and above, or anyone with dashboard
access) gets the staff policy instead (MemberPassword::ruleFor()), and the
change settles a required password change the way the staff settings page
does. It rotates the remember-me token, so another
machine's remember-me cookie stops signing in. Five wrong current passwords in
15 minutes pause the form for that account. Forgot it? signs the member out
and opens the reset request, because the reset pages are for signed-out
visitors. The card shows only for an account that has a password (a
passkey-only account has none to change).
Tests: LoginShieldTest, LoginLockoutTest, MemberLoginShieldTest, MemberPasswordBreachCheckTest, SignInBreachCheckTest, AccountUnlockTest.
Trusted proxies, HTTPS and mailed links
How the site reads each request decides what ip(), isSecure() and every
generated link say, so it sits under all of the above.
- The Trusted Proxies switch (Dashboard → Settings → Advanced) trusts
Cloudflare's published edge ranges and nothing else
(CloudflareRanges, with each
IPv4 range repeated in its
::ffff:form for servers that report IPv4 peers that way). It is off by default. Any other CDN or proxy goes inTRUSTED_PROXIESin.env, which overrides the switch; there,*means only the address directly upstream (REMOTE_ADDR), never every address. When trust is on but the request came through a proxy the list doesn't cover, the card says so. - Only two forwarded headers are believed,
X-Forwarded-ForandX-Forwarded-Proto(TrustProxies). Cloudflare passes a client's ownX-Forwarded-Hostthrough, so believing it would let any visitor rewrite the host in generated links. - HTTPS comes from APP_URL. When APP_URL is
https://and a request doesn't read as secure (Cloudflare's Flexible SSL talks http to the origin), the request is marked secure. That keeps passkey sign-in available, the Secure flag on the visitor cookies the CMS sets per request (the session cookie is Secure in production on its own, fromsession.secure),https://links, and absolute signed links (email verification, donation statements) valid. A trusted Cloudflare hop that says the visitor used plain http still wins. - Links that carry a secret are built on APP_URL, never the request's Host, which a request sent straight to the origin can set to anything: staff and member password resets, member set-password and seat invites, staff invites, and email verification (SiteUrl).
Tests: TrustedProxyHeadersTest, TrustedProxyToggleTest, HttpsFromAppUrlTest, MailedLinksUseAppUrlTest.
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.