Skip to main content

Documentation

No results found.
Features

Login Protection (LoginShield)

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 s...

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.

  1. 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@ finds jane@). 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.
  2. 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 login limiter is now Limit::none()), and it stays on when the challenge is switched off.
  3. 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_device cookie (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.
  4. 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 by LoginShield::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 (the spam-shield-challenge limiter) 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.
  5. 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_detected to the Activity Log (key_type email or site); a locked account also writes login_account_locked (once per window — including when a lowered threshold locks it without any new failure).
  6. 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 before EnforceLoginShield so 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 in signIn() right after Auth::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 conditional UPDATE), 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 with config('cms.security.breach_check_on_login') (env CMS_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.

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 in TRUSTED_PROXIES in .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-For and X-Forwarded-Proto (TrustProxies). Cloudflare passes a client's own X-Forwarded-Host through, 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, from session.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.

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.