turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

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.

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

Also in Auth and Security