Skip to Content
Course content

175: Rate Limiting Login Attempts

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've already covered how to verify passwords using password_verify(), but if you leave a login form wide open, you're basically inviting a botnet to guess passwords until they get lucky. We need to implement rate limiting. Now, there are fancy ways to do this with Redis or Memcached, but for most of the projects you'll handle, a simple database table is the most reliable way to track these attempts.

Creating the Tracking Table

First, we need a place to store the failures. I usually create a dedicated login_attempts table. I don't want to clutter my main users table with "last failed attempt" timestamps because that makes the users table bulky and harder to cache.

CREATE TABLE login_attempts (
    ip_address VARCHAR(45) NOT NULL,
    attempt_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (ip_address, attempt_time)
);

I'm using VARCHAR(45) for the IP to ensure we support IPv6. I'm not storing a "count" integer here; instead, I'm logging every single failed attempt as a row. This gives us a better audit trail if we ever need to analyze an attack pattern.

Blocking the Request Early

The most important rule of rate limiting: check the limit before you do any expensive work. Don't query the user's password hash or hit the database for user details if the IP is already blocked. It saves CPU and prevents a different kind of Denial of Service attack.

$ip = $_SERVER['REMOTE_ADDR'];
$maxAttempts = 5;
$lockoutTime = 15 * 60; // 15 minutes in seconds

$stmt = $pdo->prepare("SELECT COUNT(*) FROM login_attempts 
                       WHERE ip_address = ? AND attempt_time > NOW() - INTERVAL 15 MINUTE");
$stmt->execute([$ip]);
$attempts = $stmt->fetchColumn();

if ($attempts >= $maxAttempts) {
    die("Too many failed attempts. Please try again in 15 minutes.");
}

Tracking the Failures

Now we get into the actual login logic. If the password check fails, we log the attempt. It's a simple insert. I've kept this concise because you already know how to handle the basic POST data and password_verify() logic from the previous lessons.

if (!password_verify($password, $user['password_hash'])) {
    $stmt = $pdo->prepare("INSERT INTO login_attempts (ip_address) VALUES (?)");
    $stmt->execute([$ip]);
    echo "Invalid credentials.";
    exit;
}

Wait, I Forgot the Reset

Here is where I messed up when I first implemented this in a production app. I wrote all the logic to block people, but I completely forgot to clear the history when the user actually succeeded.

I tested it and it worked great. But then I realized that if a user fails four times, then finally gets it right, their "failure count" is still four. If they accidentally mistype their password just once more a week later, they're instantly locked out because those old failed attempts were still hanging around in the table. It created a terrible user experience for people who just happen to be clumsy with their keyboards.

Cleaning up the Logic

To fix this, we need to wipe the slate clean the moment a user successfully authenticates. This ensures that a legitimate user isn't penalized for mistakes they made days ago.

// Inside the successful login block:
if (password_verify($password, $user['password_hash'])) {
    // Success! Now, clear the failed attempts for this IP
    $stmt = $pdo->prepare("DELETE FROM login_attempts WHERE ip_address = ?");
    $stmt->execute([$ip]);
    
    $_SESSION['user_id'] = $user['id'];
    echo "Welcome back!";
}

By adding that DELETE query, we ensure the rate limit only triggers during an active "attack" phase. Just a word of caution: in a massive-scale app, you'd probably want a cron job to clean up the login_attempts table every night so it doesn't grow to millions of rows of useless data.




📋 Practical Task

Building a Dynamic Lockout Duration

Currently, our lockout is a flat 15 minutes. To make this more secure, your task is to modify the logic to implement a "progressive" lockout.

  • Modify the SELECT COUNT(*) query or the subsequent if statement so that the lockout duration increases based on the number of attempts.
  • If attempts are between 5 and 10, lock them out for 15 minutes.
  • If attempts exceed 10, lock them out for 1 hour.
  • Update the error message to tell the user exactly how long they are blocked for based on these new tiers.

Implement this logic in a PHP script that simulates a login attempt by checking a mock login_attempts database table.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.