Login Throttling
Temporarily blocks repeated failed logins per IP + username, before the password is checked.
Current settings
| Failures before blocking | 5 per IP + username |
|---|---|
| IP-wide cap | 50 per IP, any username |
| Window / cooldown | 5 minutes |
| Counted per | IP address + username (trimmed, lowercased) |
| Stored in | login_attempts table (failures only, username as SHA-256) |
How it works
-
A login POST reaches
ThrottledFormAuthenticator, which replacesAuthentication.ForminApplication.php. - Before the password is checked, it counts the failures for this IP + username in the last 5 minutes.
- 5 or more for this IP + username, or 50 or more for this IP with any username: the login is refused without checking the password, even if it is correct. The response is HTTP 429. Nothing new is recorded while blocked.
- Otherwise the password is checked as usual. A failure is recorded; a success clears the failures for that IP + username.
- The block lifts on its own once the old failures are older than the window. Old rows are purged each time a new failure is recorded.
Messages
| Wrong email or password | Email or password is incorrect. |
|---|---|
| Blocked (HTTP 429) | Too many failed login attempts. Please try again in a few minutes. |
Both are the same whether or not the account exists, and unknown usernames are blocked exactly like real ones, so the block cannot be used to find out which accounts are real.
Try it
- Open the login page and enter any email with a wrong password 5 times.
- The next attempt shows the "Too many failed login attempts" message, even with the right password.
- A different email from the same browser still gets the normal message.
-
Watch the
login_attemptstable (e.g. in phpMyAdmin): one row per failure, no raw usernames. Wait 5 minutes, or delete the rows, to unblock.
Behind a proxy or CDN
By default the IP is REMOTE_ADDR and forwarded headers are ignored. If the app sits
behind a proxy, set TRUSTED_PROXIES (comma-separated exact IPs) in the server
php.ini or the Docker env. X-Forwarded-For is then used only when the
request comes from one of those IPs.
PHP cannot protect itself from a flood of requests: each one boots PHP before this check runs.
Rate limiting belongs in front of the app (CDN, mod_evasive, or fail2ban watching the
access log for 429 on the login URL).
Setup on a project
- Run the schema SQL below on each environment (there is no automatic schema step yet).
-
New projects get the files and the
Application.phpchange from the CodeBlocks build. On an existing project, re-runinit-web/9-Install-CodeBlocks_application.php. - Set
TRUSTED_PROXIESonly if the app is behind a proxy or CDN.
Known limitations
- Many IPs against one username (botnet) are only limited per IP.
- An office behind one IP is blocked for everyone if it reaches the IP-wide cap in one window.
- Remember-me cookie logins and the password reset form are not throttled.
TRUSTED_PROXIEStakes exact IPs, not CIDR ranges.
Schema
# Login Throttling
# See docs/features/MVP-login_throttling.md for the full spec.
CREATE TABLE `login_attempts` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ip` varchar(45) NOT NULL COMMENT 'IPv4 or IPv6',
`username_hash` char(64) NOT NULL COMMENT 'SHA-256 of trimmed, lowercased username; raw username is never stored',
`created` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `ip_username_created` (`ip`, `username_hash`, `created`),
KEY `created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Table
<?php
declare(strict_types=1);
namespace App\Model\Table;
use Cake\I18n\FrozenTime;
use Cake\ORM\Table;
/**
* Login Throttling - failed password logins per IP + username.
* See docs/features/MVP-login_throttling.md
*
* Rolling window: a key is blocked while it has $maxFailures or more failures
* inside the last $windowMinutes. The block lifts as old failures age out.
* An IP is also blocked for every username once it has $maxFailuresPerIp
* failures in the window, so rotating usernames does not give unlimited
* password checks.
*
* NO GROUP SCOPE (deliberate AGENTS.md exception): attempts are recorded
* before anyone is logged in, so there is no group_id / user_id to scope by.
* Deletes are limited instead: rows older than the window (purge), or the
* exact ip + username_hash of a successful login (clear). Nothing deletes all rows.
*/
class LoginAttemptsTable extends Table
{
protected int $maxFailures = 5;
protected int $maxFailuresPerIp = 50;
protected int $windowMinutes = 5;
// Set by setKey(), used by the public methods
private array $key = [];
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('login_attempts');
}
public function settings(): array
{
return [
'STATUS' => 200,
'MSG' => 'Login throttling settings',
'maxFailures' => $this->maxFailures,
'maxFailuresPerIp' => $this->maxFailuresPerIp,
'windowMinutes' => $this->windowMinutes,
];
}
public function isBlocked(string $ip, string $username): array
{
if (!$this->setKey($ip, $username)) {
return ['STATUS' => 400, 'MSG' => 'IP is required'];
}
$since = ['created >=' => $this->windowStart()];
$blocked = $this->find()->where($this->key + $since)->count() >= $this->maxFailures
|| $this->find()->where(['ip' => $this->key['ip']] + $since)->count() >= $this->maxFailuresPerIp;
return [
'STATUS' => 200,
'MSG' => $blocked ? 'Too many failed attempts' : 'Not blocked',
'blocked' => $blocked,
];
}
public function recordFailure(string $ip, string $username): array
{
if (!$this->setKey($ip, $username)) {
return ['STATUS' => 400, 'MSG' => 'IP is required'];
}
$entity = $this->newEntity($this->key + ['created' => FrozenTime::now()]);
if (!$this->save($entity)) {
return ['STATUS' => 500, 'MSG' => 'Failed attempt not saved'];
}
// Precondition: only rows already outside the window are purged
$this->deleteAll(['created <' => $this->windowStart()]);
return ['STATUS' => 200, 'MSG' => 'Failed attempt recorded'];
}
public function clearFailures(string $ip, string $username): array
{
if (!$this->setKey($ip, $username)) {
return ['STATUS' => 400, 'MSG' => 'IP is required'];
}
// Filter on the full key in the same query: a different user is never touched
$deleted = $this->deleteAll($this->key);
return ['STATUS' => 200, 'MSG' => 'Failed attempts cleared', 'deleted' => $deleted];
}
// The raw username is never stored: people sometimes type their password into it
private function setKey(string $ip, string $username): bool
{
$ip = trim($ip);
if ($ip === '') {
return false;
}
$this->key = [
'ip' => $ip,
'username_hash' => hash('sha256', mb_strtolower(trim($username))),
];
return true;
}
private function windowStart(): FrozenTime
{
return FrozenTime::now()->subMinutes($this->windowMinutes);
}
}
Authenticator
<?php
declare(strict_types=1);
namespace App\Authenticator;
use Authentication\Authenticator\FormAuthenticator;
use Authentication\Authenticator\Result;
use Authentication\Authenticator\ResultInterface;
use Authentication\Identifier\IdentifierInterface;
use Cake\ORM\Locator\LocatorAwareTrait;
use Psr\Http\Message\ServerRequestInterface;
/**
* Login Throttling - FormAuthenticator that refuses a login POST while its
* IP + username has too many recent failures. See docs/features/MVP-login_throttling.md
*
* The check runs here, before the password is verified. By the time a
* controller runs, a correct password has already logged the user in.
*
* Proxies are only trusted when TRUSTED_PROXIES (comma-separated IPs) is set.
*/
class ThrottledFormAuthenticator extends FormAuthenticator
{
use LocatorAwareTrait;
public const FAILURE_THROTTLED = 'FAILURE_THROTTLED';
public function authenticate(ServerRequestInterface $request): ResultInterface
{
// Not the login URL or no credentials posted: no password is checked, nothing to count
if (!$this->_checkUrl($request) || $this->_getData($request) === null) {
return parent::authenticate($request);
}
$ip = $this->clientIp($request);
$username = (string)$this->_getData($request)[IdentifierInterface::CREDENTIAL_USERNAME];
$attempts = $this->fetchTable('LoginAttempts');
if ($attempts->isBlocked($ip, $username)['blocked'] ?? false) {
return new Result(null, self::FAILURE_THROTTLED, ['Too many failed login attempts']);
}
$result = parent::authenticate($request);
if ($result->isValid()) {
$attempts->clearFailures($ip, $username);
} else {
$attempts->recordFailure($ip, $username);
}
return $result;
}
// Not ServerRequest::setTrustedProxies(): CakePHP then trusts X-Forwarded-For
// without checking that the request really came from a listed proxy
private function clientIp(ServerRequestInterface $request): string
{
$remote = trim((string)($request->getServerParams()['REMOTE_ADDR'] ?? ''));
$proxies = (string)(get_cfg_var('TRUSTED_PROXIES') ?: env('TRUSTED_PROXIES') ?: '');
$proxies = array_values(array_filter(array_map('trim', explode(',', $proxies))));
if (!in_array($remote, $proxies, true)) {
return $remote;
}
// Right to left: the first address that is not one of our proxies is the client
$forwarded = array_map('trim', explode(',', $request->getHeaderLine('X-Forwarded-For')));
foreach (array_reverse($forwarded) as $ip) {
if ($ip !== '' && !in_array($ip, $proxies, true)) {
return $ip;
}
}
return $remote;
}
}