diff --git a/docs/README.md b/docs/README.md
index ee9c0b3ecffd..e2d4ef7d2a34 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -32,6 +32,7 @@ Descriptions say **when to load** the doc, not what it contains.
| [CONFIGURATION_PATTERNS.md](backend/CONFIGURATION_PATTERNS.md) | Reading configuration — `Config.getProperty()` and the resolution order |
| [DATABASE_PATTERNS.md](backend/DATABASE_PATTERNS.md) | Writing SQL, using `DotConnect`, or reasoning about transactions |
| [SECURITY_BACKEND.md](backend/SECURITY_BACKEND.md) | Input validation, auth, SQL/XSS prevention, secure logging on the backend specifically |
+| [LOGIN_LOCKOUT.md](backend/LOGIN_LOCKOUT.md) | Working on login lockout, configuring it, or unlocking a locked-out user (support runbook) |
| [VIRTUAL_THREADS.md](backend/VIRTUAL_THREADS.md) | Deciding whether a workload belongs on a virtual thread — socket I/O yes, file I/O no |
| [JANDEX_METADATA_SCANNING.md](backend/JANDEX_METADATA_SCANNING.md) | Looking up classes or annotations at runtime — prefer this over reflection |
| [HEALTH_MONITORING.md](backend/HEALTH_MONITORING.md) | Working on health endpoints or runtime log levels |
diff --git a/docs/backend/LOGIN_LOCKOUT.md b/docs/backend/LOGIN_LOCKOUT.md
new file mode 100644
index 000000000000..9632f94c80d5
--- /dev/null
+++ b/docs/backend/LOGIN_LOCKOUT.md
@@ -0,0 +1,72 @@
+# Login Lockout
+
+Repeated failed logins lock the failing source out of the account for a fixed time. The code is
+`com.dotcms.auth.LoginLockout`; state lives in the `login_lockout` table, so every node of a cluster sees
+the same counts and locks.
+
+## How it works
+
+- **Per account and client address.** Each address gets `auth.max.failures.limit` password attempts
+ (default 5) within the counting window. When they are used up without a success, that address is locked
+ out of that account. Other addresses, including the account owner's, are not affected.
+ - IPv6 addresses are grouped by their /64 network, since one host can usually use any address in it.
+ - WebDAV is counted on its own (`webdav:
`), so a mounted drive retrying a stale password locks
+ only WebDAV from that machine, not backend logins.
+- **Per account, all addresses together (opt-in, off by default).** When `AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES`
+ is set above 0 and failures from every address add up to it within the window, the account is locked for
+ every address. It caps guessing spread over many addresses, but anyone who knows a login name can trigger
+ it and keep that account, administrators included, locked for everyone. Enable it only if that trade-off
+ suits the site.
+- **Fixed length.** Locks last `AUTH_LOCKOUT_DURATION_SECONDS` and do not grow. A count that ages out of
+ the window, or whose lock has run out, starts over.
+- **Counted before the check.** Each attempt is counted before its password is checked, so parallel
+ requests get at most the limit between them.
+- **While locked,** the password is still checked (same timing) and the login fails exactly like a wrong
+ password. Nothing is counted.
+- **Clearing.** A successful login clears the count for its own address. A password reset, or a new
+ password set by an administrator, clears everything for the account. Deleting a user deletes their rows,
+ and rows that no longer affect any login are purged periodically.
+
+Every password check goes through it: the backend and front-end login, REST basic auth (and so the
+authentication endpoints and the CLI), WebDAV, the legacy `LoginFactory.doLogin` endpoints, Login As, and
+the current-password check when users change their own password. Failures rejected by LDAP or a WebDAV
+pre-authenticator are counted too. API tokens, SAML, OAuth and remember-me cookies check no password, so
+they are neither counted nor blocked.
+
+## Configuration
+
+| Key | Default | Meaning |
+|---|---|---|
+| `FEATURE_FLAG_AUTH_LOGIN_LOCKOUT` | `true` | Turns lockout on or off. Env var: `DOT_FEATURE_FLAG_AUTH_LOGIN_LOCKOUT`. |
+| `auth.max.failures.limit` | `5` | Attempts per address and account in a window (portal property). `0` also turns lockout off. |
+| `AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES` | `0` (off) | Failures per account from all addresses in a window before the account is locked for everyone. Set above 0 to enable. |
+| `AUTH_LOCKOUT_WINDOW_SECONDS` | `3600` | How long attempts are counted together. |
+| `AUTH_LOCKOUT_DURATION_SECONDS` | `900` | Lock length. |
+
+## Behind a proxy or load balancer
+
+The client address is `HttpServletRequest.getRemoteAddr()`. Tomcat's `RemoteIpValve` replaces it with the
+`X-Forwarded-For` client only when the request comes from a trusted proxy. The bundled `server.xml` trusts
+private address ranges by default; set `CMS_REMOTEIP_INTERNALPROXIES` for anything else.
+
+If the proxy isn't trusted, every user behind it shares one address. Five wrong passwords from anyone then
+lock everyone behind that proxy out of the account. When one address is locked out of five or more
+accounts at once, dotCMS logs a warning that names it. That warning means either one client is trying many
+accounts, or the address is an untrusted proxy.
+
+## Support runbook: a user is locked out
+
+1. **Try another network, or wait.** Locks are per address and last 15 minutes by default, so the user can
+ log in from another network or wait. Only the account-wide limit, if a site has enabled it, blocks every
+ address.
+2. **Unlock now:** reset the password through the "Forgot password" email, or have an administrator set a
+ new password in Users. Either clears all lockout state for the account.
+3. **Unlock without changing the password:** with database access, clear the state directly.
+ - Everything for the user: `DELETE FROM login_lockout WHERE user_id = '';`
+ - One address only: `DELETE FROM login_lockout WHERE user_id = '' AND source = '';`
+4. **Diagnose:** `SELECT source, failures, to_timestamp(locked_until / 1000) FROM login_lockout WHERE
+ user_id = '';` shows each source's count and lock. The security log records every lock with
+ its source.
+5. **Every lock shows the same proxy or load-balancer address:** Tomcat isn't seeing real client
+ addresses. Set `CMS_REMOTEIP_INTERNALPROXIES` (see above).
+6. **Turn lockout off:** set `DOT_FEATURE_FLAG_AUTH_LOGIN_LOCKOUT=false`.
diff --git a/dotCMS/src/main/java/com/dotcms/auth/LoginLockout.java b/dotCMS/src/main/java/com/dotcms/auth/LoginLockout.java
new file mode 100644
index 000000000000..5987c1e23a12
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/auth/LoginLockout.java
@@ -0,0 +1,636 @@
+package com.dotcms.auth;
+
+import com.dotcms.api.web.HttpServletRequestThreadLocal;
+import com.dotcms.business.CloseDBIfOpened;
+import com.dotcms.featureflag.FeatureFlagName;
+import com.dotmarketing.cms.login.factories.LoginFactory;
+import com.dotmarketing.common.db.DotConnect;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.util.Config;
+import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.SecurityLogger;
+import com.dotmarketing.util.UtilMethods;
+import com.google.common.net.InetAddresses;
+import com.liferay.portal.model.User;
+import com.liferay.portal.util.PropsUtil;
+import com.liferay.util.GetterUtil;
+
+import javax.servlet.http.HttpServletRequest;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.BiPredicate;
+import java.util.function.LongSupplier;
+
+/**
+ * Login lockout keyed on the account and the client address, so failed logins from one place cannot
+ * lock the account owner out from another. Two counts are kept in the {@code login_lockout} table,
+ * shared by every node of a cluster:
+ *
+ * - Per account and client address. Each address gets {@code auth.max.failures.limit}
+ * password attempts within the counting window. When they are used up without a success, that
+ * address is locked out of that account. Other addresses, including the owner's, are not affected.
+ * IPv6 addresses are grouped by their /64 prefix, which one host can rotate through. WebDAV is
+ * counted apart from other logins ({@value #WEBDAV_SOURCE_PREFIX}address), so a mounted drive
+ * retrying a stale password locks only WebDAV from that machine.
+ * - Per account, all addresses together (source {@value #ACCOUNT_WIDE}). An optional, looser
+ * backstop, off by default: once failures from every address add up to
+ * {@code AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES} within the window, the account is locked for everyone. It
+ * caps guessing spread over many addresses, but anyone who knows a login name can then trigger it, so
+ * it is opt-in.
+ *
+ * An attempt is counted against its address before the password is checked ({@link #begin}), so
+ * parallel requests cannot all pass a check made before any of them was counted. Locks last a fixed
+ * time and do not grow. While a lock applies every password check fails, including the correct
+ * password, and nothing is counted. A successful login clears the count for its own address; a password
+ * reset or a new password set by an administrator clears everything for the account.
+ *
+ * The client address is {@link HttpServletRequest#getRemoteAddr()}. Behind a proxy it is the real
+ * client only when Tomcat's {@code RemoteIpValve} trusts that proxy (the bundled {@code server.xml}
+ * trusts private ranges by default); otherwise every login shares the proxy's address. A warning is
+ * logged, at most once a minute per node, for any address locked out of several accounts, which is what
+ * that looks like.
+ *
+ *
The main login flow ({@code UserManagerImpl#authenticate}) uses {@link #begin},
+ * {@link #succeeded} and {@link #failed} directly. Other places that check a password (WebDAV, the
+ * legacy {@code LoginFactory.doLogin} methods, Login As and the current-password check on a user update)
+ * go through {@link #passwordMatch(String, User, String, BiPredicate)}. Passwords checked by an external
+ * authenticator such as LDAP are counted with {@link #countFailure(String, String)}.
+ *
+ *
Configuration:
+ *
+ * - {@code FEATURE_FLAG_AUTH_LOGIN_LOCKOUT} (default {@code true})
+ * - {@code auth.max.failures.limit}: attempts per address (portal property, default 5)
+ * - {@code AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES}: failures per account from all addresses
+ * (default 0, which leaves the account-wide lock off)
+ * - {@code AUTH_LOCKOUT_WINDOW_SECONDS}: how long attempts are counted together (default 3600)
+ * - {@code AUTH_LOCKOUT_DURATION_SECONDS}: lock length (default 900)
+ *
+ */
+public final class LoginLockout {
+
+ public static final String LOCKOUT_ENABLED = FeatureFlagName.FEATURE_FLAG_AUTH_LOGIN_LOCKOUT;
+ public static final String LOCKOUT_DURATION_SECONDS = "AUTH_LOCKOUT_DURATION_SECONDS";
+ public static final String LOCKOUT_WINDOW_SECONDS = "AUTH_LOCKOUT_WINDOW_SECONDS";
+ public static final String LOCKOUT_ACCOUNT_MAX_FAILURES = "AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES";
+
+ /** Source of the account-wide row. Never a client address. */
+ public static final String ACCOUNT_WIDE = "*";
+
+ /** Source used when there is no request to take an address from. */
+ public static final String UNKNOWN_SOURCE = "unknown";
+
+ /** Prefix that keeps WebDAV attempts apart from other logins from the same address. */
+ public static final String WEBDAV_SOURCE_PREFIX = "webdav:";
+
+ private static final int MAX_ADDRESS_LENGTH = 64;
+
+ /** Bytes of an IPv6 address that identify its /64 network. */
+ private static final int IPV6_PREFIX_BYTES = 8;
+
+ /** An address locked out of this many accounts at once is reported as a likely shared proxy address. */
+ private static final int SHARED_SOURCE_WARNING_ACCOUNTS = 5;
+
+ private static final long MAINTENANCE_INTERVAL_MILLIS = 60_000L;
+
+ /**
+ * Counts one attempt for an account and source and returns the new count and the source's lock.
+ * Both CASE conditions mean the count starts over: it is older than the window, or its lock has run
+ * out. Parameters: user, source, now, mod date, then window cutoff and now (failures), window
+ * cutoff, now and now (window start), now (lock), mod date.
+ */
+ private static final String COUNT_SQL =
+ "INSERT INTO login_lockout (user_id, source, failures, window_start, locked_until, mod_date)"
+ + " VALUES (?, ?, 1, ?, 0, ?) ON CONFLICT (user_id, source) DO UPDATE SET"
+ + " failures = CASE WHEN (login_lockout.window_start < ? OR (login_lockout.locked_until > 0"
+ + " AND login_lockout.locked_until <= ?)) THEN 1 ELSE login_lockout.failures + 1 END,"
+ + " window_start = CASE WHEN (login_lockout.window_start < ? OR (login_lockout.locked_until > 0"
+ + " AND login_lockout.locked_until <= ?)) THEN ? ELSE login_lockout.window_start END,"
+ + " locked_until = CASE WHEN login_lockout.locked_until <= ? THEN 0"
+ + " ELSE login_lockout.locked_until END,"
+ + " mod_date = ? RETURNING failures, locked_until";
+
+ /** Hands back one attempt that turned out not to be a password failure. */
+ private static final String UNCOUNT_SQL =
+ "UPDATE login_lockout SET failures = failures - 1 WHERE user_id = ? AND source = ? AND failures > 0";
+
+ /** Locks an account and source. Parameters: user, source, now, locked until, mod date, then locked until, now, mod date. */
+ private static final String LOCK_SQL =
+ "INSERT INTO login_lockout (user_id, source, failures, window_start, locked_until, mod_date)"
+ + " VALUES (?, ?, 0, ?, ?, ?) ON CONFLICT (user_id, source) DO UPDATE SET"
+ + " locked_until = ?, failures = 0, window_start = ?, mod_date = ?";
+
+ /** Clears one source's count after a success, but never a lock in force. Parameters: user, source, now. */
+ private static final String CLEAR_SOURCE_SQL =
+ "DELETE FROM login_lockout WHERE user_id = ? AND source = ? AND locked_until <= ?";
+
+ /**
+ * Sources locked out of at least a given number of accounts right now, with that number. Parameters: now,
+ * account-wide source, minimum accounts.
+ */
+ private static final String SHARED_SOURCES_SQL =
+ "SELECT source, COUNT(DISTINCT user_id) AS accounts FROM login_lockout"
+ + " WHERE locked_until > ? AND source <> ? GROUP BY source HAVING COUNT(DISTINCT user_id) >= ?";
+
+ /**
+ * Removes rows that no longer affect any login: their lock has run out (or never existed) and their
+ * count is older than the window. Parameters: now, window cutoff.
+ */
+ private static final String PURGE_SQL =
+ "DELETE FROM login_lockout WHERE locked_until <= ? AND window_start < ?";
+
+ /**
+ * Lock state of one account as seen from one source.
+ */
+ public enum Status {
+ /** Nothing is recorded for this source and nothing is locked. */
+ NONE,
+ /** Attempts from this source are recorded but nothing is locked. */
+ RECORDED,
+ /** This source, or the whole account, is locked. */
+ LOCKED
+ }
+
+ /**
+ * One login attempt, counted against its source before the password is checked. Get it from
+ * {@link #begin(String, String)} and finish it with {@link #succeeded}, {@link #failed} or
+ * {@link #notAPasswordLogin}.
+ */
+ public static final class Attempt {
+
+ private static final Attempt UNTRACKED = new Attempt(null, null, false, false, 0);
+
+ private final String userId;
+ private final String source;
+ private final boolean refused;
+ private final boolean counted;
+ private final int number;
+
+ private Attempt(final String userId, final String source, final boolean refused, final boolean counted,
+ final int number) {
+ this.userId = userId;
+ this.source = source;
+ this.refused = refused;
+ this.counted = counted;
+ this.number = number;
+ }
+
+ /**
+ * @return {@code true} when the password must not be accepted: a lock applies, or this source has
+ * used up its attempts for the window
+ */
+ public boolean isRefused() {
+ return refused;
+ }
+ }
+
+ private static final LoginLockout INSTANCE = new LoginLockout(System::currentTimeMillis);
+
+ private final LongSupplier clock;
+ private final AtomicLong lastMaintenance = new AtomicLong();
+
+ LoginLockout(final LongSupplier clock) {
+ this.clock = clock;
+ }
+
+ public static LoginLockout getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * @return the attempts allowed per source in a window ({@code auth.max.failures.limit}), 0 when unset
+ */
+ public static int maxFailures() {
+ return GetterUtil.get(PropsUtil.get(PropsUtil.AUTH_MAX_FAILURES_LIMIT), 0);
+ }
+
+ /**
+ * @return the failure limit for an account from all addresses together; 0 or less (the default) leaves the
+ * account-wide lock off
+ */
+ public static int accountMaxFailures() {
+ return Config.getIntProperty(LOCKOUT_ACCOUNT_MAX_FAILURES, 0);
+ }
+
+ /**
+ * @return the source the current request's logins are counted under, or {@value #UNKNOWN_SOURCE}
+ * outside a request
+ */
+ public static String clientAddress() {
+ return clientAddress(HttpServletRequestThreadLocal.INSTANCE.getRequest());
+ }
+
+ /**
+ * Returns the source a request's logins are counted under: its remote address, with an IPv6
+ * address reduced to its /64 network ({@code 2001:db8:1:2::/64}), since a single host can usually
+ * pick any address in its /64. An IPv4-mapped IPv6 address counts as the IPv4 address.
+ *
+ * @return the source, or {@value #UNKNOWN_SOURCE} when there is no request or address
+ */
+ public static String clientAddress(final HttpServletRequest request) {
+ final String address = null != request ? request.getRemoteAddr() : null;
+ if (!UtilMethods.isSet(address)) {
+ return UNKNOWN_SOURCE;
+ }
+ final String source = address.indexOf(':') >= 0 ? ipv6Network(address) : address;
+ return source.length() > MAX_ADDRESS_LENGTH ? source.substring(0, MAX_ADDRESS_LENGTH) : source;
+ }
+
+ /**
+ * @return the /64 network of an IPv6 literal, the IPv4 address of an IPv4-mapped one, or the input
+ * unchanged when it is not an IP literal. Parsing never does a DNS lookup.
+ */
+ private static String ipv6Network(final String address) {
+ final InetAddress parsed;
+ try {
+ // Tomcat may report a zone, as in fe80::1%eth0; the zone does not identify the host
+ final int zone = address.indexOf('%');
+ parsed = InetAddresses.forString(zone >= 0 ? address.substring(0, zone) : address);
+ } catch (final IllegalArgumentException e) {
+ return address;
+ }
+ if (!(parsed instanceof Inet6Address)) {
+ return parsed.getHostAddress();
+ }
+ final byte[] network = Arrays.copyOf(Arrays.copyOf(parsed.getAddress(), IPV6_PREFIX_BYTES), 16);
+ try {
+ // Built from raw bytes, so no lookup either
+ return InetAddresses.toAddrString(InetAddress.getByAddress(network)) + "/64";
+ } catch (final UnknownHostException e) {
+ return address;
+ }
+ }
+
+ /**
+ * @return the source WebDAV logins from the current request are counted under
+ */
+ public static String webdavSource() {
+ return WEBDAV_SOURCE_PREFIX + clientAddress();
+ }
+
+ /**
+ * @return {@code true} when lockout is switched on and a failure limit is configured
+ */
+ public boolean isEnabled() {
+ return isEnabled(maxFailures());
+ }
+
+ /**
+ * @return {@code true} when lockout is switched on and a failure limit is configured
+ */
+ public boolean isEnabled(final int maxFailures) {
+ return maxFailures > 0 && Config.getBooleanProperty(LOCKOUT_ENABLED, true);
+ }
+
+ /**
+ * Returns the lock state to enforce for a login to the account from the given source:
+ * {@link Status#NONE} when lockout is switched off, otherwise what is stored.
+ */
+ public Status check(final String userId, final String source) {
+ return isEnabled() ? status(userId, source) : Status.NONE;
+ }
+
+ /**
+ * Returns the stored lock state for the account and source, whether or not lockout is switched on.
+ * A database error is treated as {@link Status#NONE} so an outage of this table never blocks every
+ * login; the error is logged.
+ */
+ @CloseDBIfOpened
+ public Status status(final String userId, final String source) {
+ try {
+ final List