Last updated: August 25, 2026.
A login limiter should slow repeated failures without revealing whether a username exists. Track attempts on the server, use short rolling windows, and combine account-based and network-based limits so one mechanism cannot be bypassed easily.
Store rate-limit buckets
CREATE TABLE login_rate_limits (
bucket_hash CHAR(64) PRIMARY KEY,
attempts SMALLINT UNSIGNED NOT NULL,
window_started_at DATETIME NOT NULL,
blocked_until DATETIME NULL
);Hash normalized bucket identifiers with a server-side key before storing them. Avoid retaining raw IP addresses longer than needed.
Apply the limiter before password verification
<?php
declare(strict_types=1);
$username = mb_strtolower(trim((string) ($_POST['username'] ?? '')));
$network = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$bucket = hash_hmac('sha256', $username . '|' . $network, $_ENV['RATE_LIMIT_KEY']);
if ($limiter->isBlocked($bucket)) {
http_response_code(429);
exit('Sign-in is temporarily unavailable. Please try again later.');
}
$user = $users->findForLogin($username);
$valid = $user !== null && password_verify((string) $_POST['password'], $user->passwordHash);
if (!$valid) {
$limiter->recordFailure($bucket);
exit('The username or password is incorrect.');
}
$limiter->clear($bucket);
session_regenerate_id(true);Recommended safeguards
- Use prepared statements and atomic database updates.
- Increase delays progressively and cap the blocking period.
- Return the same message for unknown users and wrong passwords.
- Log suspicious patterns without recording passwords or session tokens.
- Add MFA and bot protection for higher-risk accounts.
- Provide a secure recovery path so attackers cannot permanently lock out a user.