In this lab you build a PHP login flow from a deliberately broken starting point, exploit its flaws, then harden it with password_hash(), session-fixation defence, and brute-force throttling.
By the end of this lab you will be able to:
- Store credentials with
password_hash()/password_verify()instead of plaintext or fast hashes. - Defeat session fixation by regenerating the session ID on privilege change.
- Throttle repeated failed logins to blunt online password guessing.
- Use
hash_equals()and generic error messages to remove user-enumeration and timing side channels.
- PHP 8.2 or 8.3 CLI with PDO SQLite enabled (
php -m | grep pdo_sqlite). - Basic familiarity with sessions, cookies, and
curl. - Understanding of SQL prepared statements — see Prepared-Statements.
Create a working directory and the SQLite schema.
-- schema.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
fail_count INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER NOT NULL DEFAULT 0
);Seed one user with a properly hashed password:
<?php
// seed.php
declare(strict_types=1);
$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
$hash = password_hash('S3cr3t-Pass!', PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
$stmt->execute(['alice', $hash]);
echo "Seeded user alice\n";Run the setup and start the built-in server:
php seed.php
php -S 127.0.0.1:8000Save this as login_vuln.php. It "works" but is riddled with flaws.
<?php
// login_vuln.php -- DO NOT SHIP
declare(strict_types=1);
session_start();
$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = $_POST['username'] ?? '';
$pass = $_POST['password'] ?? '';
// VULNERABLE: string-concatenated SQL -> SQL injection
$row = $pdo->query(
"SELECT * FROM users WHERE username = '$user'"
)->fetch(PDO::FETCH_ASSOC);
// VULNERABLE: plaintext comparison + user enumeration
if (!$row) {
exit('No such user'); // reveals the account does not exist
}
if ($row['password_hash'] === $pass) {
// VULNERABLE: no session_regenerate_id() -> session fixation
$_SESSION['uid'] = $row['id'];
exit('Welcome ' . $row['username']);
}
exit('Wrong password'); // distinct message -> enumeration
}
?>
<form method="post">
<input name="username"><input name="password" type="password">
<button>Login</button>
</form>User enumeration. Two different error strings leak which usernames exist:
curl -s -d 'username=alice&password=x' http://127.0.0.1:8000/login_vuln.php # "Wrong password"
curl -s -d 'username=bob&password=x' http://127.0.0.1:8000/login_vuln.php # "No such user"SQL injection auth bypass. The seed above stores a hash, so plaintext compare never matches — but injection makes the query itself return whatever we want. Because the code compares password_hash === $pass, we inject a row whose hash column equals a value we control:
curl -s -d "username=alice' UNION SELECT 1,'alice','pwned',0,0-- -&password=pwned" \
http://127.0.0.1:8000/login_vuln.php
# -> "Welcome alice" (authenticated with no valid credential)Session fixation. An attacker fixes a victim's session ID, waits for them to log in, then reuses it:
# Attacker sets a known ID, victim logs in, attacker is now authenticated as the victim.
curl -s -b 'PHPSESSID=attacker_fixed_id' -d 'username=alice&password=...' \
http://127.0.0.1:8000/login_vuln.phpNo throttling. Nothing stops thousands of guesses per second — pure online brute force.
Replace with login_secure.php. Every flaw above is closed.
<?php
// login_secure.php
declare(strict_types=1);
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => true, // requires HTTPS in production
]);
const MAX_FAILS = 5;
const LOCK_WINDOW = 900; // 15 minutes
$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = (string) ($_POST['username'] ?? '');
$pass = (string) ($_POST['password'] ?? '');
// Prepared statement — no injection possible.
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ?');
$stmt->execute([$user]);
$row = $stmt->fetch();
$now = time();
// Throttle: reject while locked, regardless of correctness.
if ($row && $row['locked_until'] > $now) {
$error = 'Too many attempts. Try again later.';
} else {
// Constant-work verify. Run a dummy hash when the user is unknown
// so response time does not leak account existence.
$ok = $row
? password_verify($pass, $row['password_hash'])
: password_verify($pass, '$2y$10$usesomesillystringforsalt0.no.match.here.padding0');
if ($ok && $row) {
// Kill session fixation: new ID on privilege change.
session_regenerate_id(true);
$_SESSION['uid'] = $row['id'];
// Reset the failure counter.
$pdo->prepare('UPDATE users SET fail_count = 0, locked_until = 0 WHERE id = ?')
->execute([$row['id']]);
// Opportunistic rehash if the cost/algorithm has moved on.
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($pass, PASSWORD_DEFAULT), $row['id']]);
}
header('Location: /dashboard.php');
exit;
}
// Failure path: increment counter and maybe lock.
if ($row) {
$fails = (int) $row['fail_count'] + 1;
$lock = $fails >= MAX_FAILS ? $now + LOCK_WINDOW : 0;
$pdo->prepare('UPDATE users SET fail_count = ?, locked_until = ? WHERE id = ?')
->execute([$fails, $lock, $row['id']]);
}
// Generic message — same for wrong user and wrong password.
$error = 'Invalid username or password.';
}
}
?>
<!doctype html>
<form method="post">
<?php if ($error !== null): ?>
<p><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<input name="username" autocomplete="username">
<input name="password" type="password" autocomplete="current-password">
<button>Login</button>
</form># Injection now fails — the payload is a literal username.
curl -s -d "username=alice' UNION SELECT 1,'a','b',0,0-- -&password=pwned" \
http://127.0.0.1:8000/login_secure.php # -> Invalid username or password.
# Enumeration gone — identical message for bob and alice.
curl -s -d 'username=bob&password=x' http://127.0.0.1:8000/login_secure.php
curl -s -d 'username=alice&password=x' http://127.0.0.1:8000/login_secure.php
# Sixth wrong guess locks alice for 15 minutes.
for i in $(seq 1 6); do
curl -s -d 'username=alice&password=wrong' http://127.0.0.1:8000/login_secure.php >/dev/null
done
curl -s -d 'username=alice&password=S3cr3t-Pass!' http://127.0.0.1:8000/login_secure.php
# -> Too many attempts. Try again later. (correct password still blocked while locked)- Prove regeneration. Log a successful login, capturing
session_id()immediately before and aftersession_regenerate_id(true). Confirm the value changes and the old cookie is invalidated. - Timing check. Time 100 requests for an existing vs. non-existing user with
curl -w '%{time_total}\n'. Explain why the dummypassword_verify()keeps the distributions overlapping. - Rehash on upgrade. Re-seed alice with
PASSWORD_BCRYPTand cost 4, then log in and confirmpassword_needs_rehash()upgrades her stored hash to the default. - Constant-time compare. Add a "remember me" token stored server-side and compare the client cookie with
hash_equals(). Explain why===would be unsafe here.
The current per-account lockout lets an attacker deny service to a victim by deliberately failing five logins as them. Redesign the throttle so it resists both online guessing and this account-lockout denial-of-service. Combine at least two of: per-IP rate limiting, an exponential backoff delay, and a CAPTCHA / proof-of-work gate after N failures — without ever locking a legitimate user out purely because someone else attacked their username.
[!success]- Solution A robust design separates identity throttling from hard lockout. Track failures in two buckets: per source IP and per account, each with its own counter and sliding window (a small
login_attemptstable keyed byipandusernamewith timestamps, or a Redis token bucket — see Rate-Limiting). Apply an exponential backoff delay (e.g.sleep(min(2 ** $fails, 30)), or better, a storednext_allowed_attimestamp so you never hold a worker) rather than a flat block. After a threshold, require a CAPTCHA or proof-of-work instead of locking the account — this stops bots while a real user can still get in. Reserve hard account lockout for extreme cases and pair it with an out-of-band unlock (email link). Because the IP bucket absorbs distributed guessing and the account never fully locks from a third party's failures, you defeat guessing and the lockout-DoS. Always send the same generic message and comparable timing whether the block is IP- or account-driven, so the throttle itself does not become an enumeration oracle.
Root cause. The vulnerable version trusted user input in SQL, compared secrets in plaintext, kept the pre-auth session ID after login, and imposed no cost on guessing. Each is a distinct control failure that composes into full account takeover.
Defence in depth. No single control is sufficient: prepared statements stop injection, password_hash() (bcrypt/Argon2 — slow and salted) makes stolen hashes expensive to crack, session_regenerate_id(true) plus HttpOnly/SameSite/Secure cookies protect the session, throttling raises the cost of online guessing, and generic messages with constant-work verification remove enumeration and timing oracles. Serve everything over HTTPS so credentials and cookies are never in cleartext.
Blue-team detection. Alert on spikes in failed logins per IP and per account, on many distinct usernames failing from one IP (spraying), and on successful logins immediately following a burst of failures. Log locked_until transitions and geovelocity anomalies. Feed failed-auth events to your SIEM and rate-limit at the edge (WAF / reverse proxy) as a second layer above the application counter.
- PHP Manual —
password_hash() - PHP Manual —
session_regenerate_id() - PHP Manual —
hash_equals() - OWASP Authentication Cheat Sheet
- OWASP Session Management Cheat Sheet
- OWASP Blocking Brute Force Attacks
- Password-Based-Login — the concept note this lab operationalises end to end.
- Prepared-Statements — the injection fix in Step 3 depends on parameterised queries.
- SQL-Injection-Prevention — why the concatenated
WHERE username = '$user'was exploitable. - Rate-Limiting — the throttling and backoff strategy behind the Challenge solution.
- Output-Encoding-XSS-Prevention —
htmlspecialchars()on the error message keeps reflected input safe. - Role-Based-Access-Control — the natural next step once a user is authenticated.