SEC-005
Throttle logins by account and by address, separately
One attacker with many addresses and many attackers on one address are different attacks. A single counter catches only one of them.
Rate limiting a login form by address stops someone hammering one machine against one account. It does nothing about a list of addresses trying the same password against every account you have, which is what credential stuffing actually looks like.
Counting both — attempts against this account, and attempts from this address — catches both shapes. The two counters have different limits and different windows, because the attacks have different rhythms.
Give the same answer either way. A message that says the account is locked confirms the account exists, which is half of what the attacker wanted.
function login_allowed(PDO $pdo, string $username, string $ip): bool
{
// Per address: a machine trying many accounts.
if (!rate_hit($pdo, 'login-ip:' . $ip, 20, 900)) {
return false;
}
// Per account: many machines trying one account.
if (!rate_hit($pdo, 'login-user:' . strtolower($username), 8, 900)) {
return false;
}
return true;
}
function attempt_login(PDO $pdo, string $username, string $password, string $ip): ?array
{
if (!login_allowed($pdo, $username, $ip)) {
usleep(400000);
return null; // same answer as a wrong password
}
$st = $pdo->prepare('SELECT id, pass_hash, status FROM users WHERE username = ?');
$st->execute([$username]);
$user = $st->fetch(PDO::FETCH_ASSOC);
// Hash even when the user does not exist, so the timing does not reveal it.
$hash = $user['pass_hash'] ?? '$2y$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidinva';
if (!password_verify($password, $hash) || !$user) {
usleep(400000);
return null;
}
if ((int) $user['status'] !== 1) {
return null; // suspended: same generic answer
}
session_regenerate_id(true);
return $user;
}
Using it
Clear the per-account counter on a successful login so a user who mistyped twice is not locked out for the rest of the window.
Log failures with the address and the username tried. A list of usernames that do not exist on your site is someone working through a leaked list from elsewhere.
What bites people
Always run password_verify, even for an unknown username. Returning early makes a nonexistent account measurably faster to reject, which enumerates your user list.
Per-account throttling is itself a denial of service: an attacker can lock a known user out deliberately. That is why the window is short and the limit is not one.
This one touches real money. Point it at a throwaway wallet and watch a full cycle before you trust it with a live balance.