PHP
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions
-
Section 4: Object-Oriented PHP
-
Section 5: Working with Data
-
Section 6: Modern PHP (PHP 8)
-
Section 7: Working with Files and Networking
-
Section 8: Common PHP Frameworks Overview
-
Section 9: Tooling and Ecosystem
-
Section 10: Practical Projects
-
Section 11: Interview Practice
-
Section 12: More Standard Library
-
Section 13: Data Structures and Algorithms in PHP
-
Section 14: More Practice Exercises
-
Section 15: More Security and Best Practices
-
Section 16: More Testing and Tooling
-
Section 17: WordPress-Style CMS Concepts
-
Section 18: Advanced OOP Practice
-
Section 19: More Web Fundamentals
-
Section 20: Database Practice
-
Section 21: PHP Manual: Array Functions
-
Section 22: PHP Manual: Date and Calendar Functions
-
Section 23: PHP Manual: Filesystem and Directory Functions
-
Section 24: PHP Manual: Filter and Var Handling
-
Section 25: PHP Manual: Math Functions
-
Section 26: PHP Manual: JSON and XML
-
Section 27: PHP Manual: Network and Stream Functions
-
Section 28: PHP Manual: Error and Exception Handling
-
Section 29: PHP Manual: Output Control and Misc
-
Section 30: PHP Manual: FTP, Zip, and Mail
-
Section 31: Modern PHP Frameworks Deep Dive
-
Section 32: PHP Design Patterns
-
Section 33: More Practice Exercises
-
Section 34: PHP Performance and Deployment
-
Section 35: More Interview Practice
-
Section 36: More PHP Standard Library
-
Section 37: PHP Concurrency and Async
-
Section 38: More Web Development Practice
-
Section 39: PHP Testing Deep Dive
-
Section 40: Composer and Package Development
-
Section 41: PHP Security Deep Dive
-
Section 42: More Practical Projects
-
Section 43: Legacy PHP Maintenance
-
Section 44: More Algorithm Practice
-
Section 45: Final Practice and Review
-
Section 46: PHP for E-Commerce Patterns
-
Section 47: PHP API Design Deep Dive
-
Section 48: PHP Caching Strategies
-
Section 49: PHP Queue and Background Jobs
-
Section 50: PHP Multi-Tenancy Patterns
-
Section 51: PHP Real-Time Features
-
Section 52: PHP CMS and Content Modeling
-
Section 53: PHP Internationalization
-
Section 54: More Framework-Specific Practice
-
Section 55: PHP Legacy Code Refactoring
-
Section 56: More Practice Projects Round 2
-
Section 57: PHP Command-Line Applications
-
Section 58: PHP and Microservices
-
Section 59: More Interview and Review Round 2
175: Rate Limiting Login Attempts
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 subsequentifstatement 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.
There are no comments for now.