Educational Content: This article is written for educational purposes to help developers and cybersecurity students understand software concepts. Always follow ethical guidelines and applicable laws.
Password security has a strange problem in 2026: developers implement "strong password requirements" that actually make users less secure. The 8-character minimum, one uppercase, one number, one special character rule — the one that's been on every security checklist for fifteen years — is increasingly understood by security researchers to be counterproductive. Meanwhile, the actual ways passwords fail in production are different from what most security checklists address, and the gap between current practice and current threat reality is significant.
This post is about the mechanics of how password-based authentication gets compromised, what the NIST guidelines actually say about password policies in 2026, and how to implement password handling in PHP that holds up against current attack techniques.
How Passwords Actually Get Compromised
Understanding the real attack vectors shapes the right defenses. Passwords fail through a handful of mechanisms that are worth understanding concretely:
Credential stuffing. This is the dominant attack vector in 2026. Attackers take credential databases from past breaches — billions of username/password pairs are available in credential markets — and automatically test them against other services. Because people reuse passwords, a significant percentage of any service's users have credentials that appeared in some previous breach. A 2025 SpyCloud analysis found that 64% of users whose credentials appeared in a breach were reusing the same password on at least one other service. Credential stuffing is why "breached password" checking at registration and login matters more than password complexity rules.
Password spraying. Instead of trying many passwords against one account (which triggers lockouts), attackers try one or a few extremely common passwords against thousands of accounts. "Password123!", "Summer2026!", "Welcome1" — these patterns are used by enough users that spraying yields results even with aggressive lockout policies. The defense isn't lockout alone; it's checking against known common passwords.
Database breach and offline cracking. When a database breach exposes password hashes, attackers download the hashes and crack them offline at billions of attempts per second using GPU clusters. Weak hashing algorithms (MD5, SHA1, unsalted SHA256) are cracked trivially. Even bcrypt can be cracked for short passwords. The defense is using memory-hard hashing algorithms and enforcing minimum password length rather than complexity.
Phishing. No amount of password complexity prevents a user from typing their password into a convincing fake login page. Multi-factor authentication is the primary defense against phishing — even if credentials are captured, the attacker can't authenticate without the second factor.
Keyloggers and malware. Local device compromise captures keystrokes before they reach your application. Hardware security keys (WebAuthn/FIDO2) are the primary defense — they're phishing-resistant and malware-resistant because the key material never leaves the hardware device.
What NIST Actually Recommends Now
NIST SP 800-63B (the US federal password guideline) was significantly revised in 2024 and many of its recommendations contradict common practice. Key points that affect developer decisions:
Do check passwords against known breach databases (Have I Been Pwned, internal lists). Reject passwords that appear in breach datasets regardless of how complex they look.
Do enforce minimum length — at least 8 characters, and NIST recommends supporting up to 64 characters or more. Length is the primary driver of password strength.
Do not require mandatory complexity rules (must contain uppercase, lowercase, number, special character). NIST explicitly recommends against these, noting they push users toward predictable patterns like "Password1!" rather than strong passwords.
Do not enforce periodic password expiration for users who haven't shown signs of compromise. Forced rotation leads to predictable password patterns (Password2026!, Password2027!) and encourages password reuse.
Do provide password strength meters to guide users toward longer, stronger passwords without mandating specific character class combinations.
Do support password managers by not blocking paste into password fields and not adding arbitrary length maximums. Many applications still break paste in password fields, explicitly sabotaging the most effective password security tool available to users.
PHP Password Hashing: The Correct Implementation
PHP's built-in password_hash() and password_verify() functions are the correct tools for password storage. They handle algorithm selection, automatic salting, and future algorithm migration. Here's a complete, production-ready implementation:
<?php
class PasswordManager {
// Current recommended algorithm with appropriate cost factor
private const HASH_ALGORITHM = PASSWORD_BCRYPT;
private const BCRYPT_COST = 12; // Adjust based on your server performance
// Cost 10 = ~100ms, Cost 12 = ~400ms, Cost 14 = ~1.5s on modern hardware
/**
* Hash a new password for storage
*/
public function hashPassword(string $password): string {
return password_hash($password, self::HASH_ALGORITHM, [
'cost' => self::BCRYPT_COST
]);
}
/**
* Verify a password against a stored hash
* Also handles automatic rehashing if algorithm/cost has changed
*/
public function verifyPassword(string $password, string $hash): bool {
return password_verify($password, $hash);
}
/**
* Check if a hash needs to be upgraded (cost factor changed, etc.)
*/
public function needsRehash(string $hash): bool {
return password_needs_rehash($hash, self::HASH_ALGORITHM, [
'cost' => self::BCRYPT_COST
]);
}
}
// In your login handler:
$passwordManager = new PasswordManager();
$stmt = $pdo->prepare("SELECT id, password_hash, is_active FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
// Use the same generic message whether email doesn't exist or password is wrong
// This prevents email enumeration attacks
if (!$user || !$passwordManager->verifyPassword($password, $user['password_hash'])) {
// Log failed attempt for rate limiting
logFailedLoginAttempt($email, $_SERVER['REMOTE_ADDR']);
http_response_code(401);
echo json_encode(['error' => 'Invalid email or password']);
exit;
}
if (!$user['is_active']) {
echo json_encode(['error' => 'Account is disabled']);
exit;
}
// Rehash if the hash algorithm or cost factor has been upgraded
if ($passwordManager->needsRehash($user['password_hash'])) {
$newHash = $passwordManager->hashPassword($password);
$stmt = $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
$stmt->execute([$newHash, $user['id']]);
}
// Create session
session_start();
session_regenerate_id(true); // Critical: prevents session fixation
$_SESSION['user_id'] = $user['id'];
$_SESSION['authenticated_at'] = time();
The needsRehash() pattern is important for long-lived applications. When you increase the bcrypt cost factor (to keep pace with hardware improvements) or migrate to a new algorithm, existing users' hashes can be silently upgraded to the new parameters the next time they log in — without forcing a password reset.
Checking Passwords Against Breach Databases
The Have I Been Pwned Passwords API lets you check whether a password appears in known breach datasets using a k-anonymity approach — you never send the full password or hash to the external service:
<?php
function isPasswordBreached(string $password): bool {
$sha1 = strtoupper(sha1($password));
$prefix = substr($sha1, 0, 5);
$suffix = substr($sha1, 5);
$context = stream_context_create([
'http' => [
'timeout' => 3, // Don't wait too long - fail open if service is down
'header' => 'User-Agent: YourApp/1.0 (+https://yoursite.com)'
]
]);
$response = @file_get_contents(
"https://api.pwnedpasswords.com/range/{$prefix}",
false,
$context
);
if ($response === false) {
// HIBP unavailable - fail open (allow the password, log the failure)
error_log('[HIBP] Service unavailable during password check');
return false;
}
$lines = explode("
", $response);
foreach ($lines as $line) {
[$hashSuffix, $count] = explode(':', trim($line));
if (strtoupper($hashSuffix) === $suffix) {
return true; // Password appears in breach database
}
}
return false;
}
// Use during registration and password changes
function validateNewPassword(string $password, string $email): array {
$errors = [];
if (strlen($password) < 10) {
$errors[] = 'Password must be at least 10 characters long.';
}
if (strlen($password) > 128) {
$errors[] = 'Password must not exceed 128 characters.';
}
// Check for obvious patterns
if (stripos($password, $email) !== false) {
$errors[] = 'Password must not contain your email address.';
}
// Check against breach database
if (empty($errors) && isPasswordBreached($password)) {
$errors[] = 'This password has appeared in data breaches. Please choose a different one.';
}
return $errors;
}
Rate Limiting and Account Lockout
Rate limiting on authentication endpoints stops brute force and credential stuffing attacks. The implementation needs to balance security with usability — too aggressive and legitimate users get locked out; too loose and automated attacks succeed.
<?php
function checkLoginRateLimit(string $identifier, PDO $db): bool {
$maxAttempts = 10; // Failed attempts before lockout
$lockoutSeconds = 900; // 15-minute lockout after exceeding attempts
$windowSeconds = 3600; // Count attempts in a rolling 1-hour window
// Count recent failed attempts
$stmt = $db->prepare(
"SELECT COUNT(*) as attempt_count
FROM login_attempts
WHERE identifier = ?
AND success = 0
AND attempted_at > NOW() - INTERVAL ? SECOND"
);
$stmt->execute([$identifier, $windowSeconds]);
$row = $stmt->fetch();
if ($row['attempt_count'] >= $maxAttempts) {
return false; // Rate limited
}
return true;
}
function recordLoginAttempt(string $identifier, bool $success, PDO $db): void {
$stmt = $db->prepare(
"INSERT INTO login_attempts (identifier, success, attempted_at, ip_address)
VALUES (?, ?, NOW(), ?)"
);
$stmt->execute([$identifier, $success ? 1 : 0, $_SERVER['REMOTE_ADDR']]);
// Prune old records periodically
if (rand(1, 50) === 1) {
$db->exec("DELETE FROM login_attempts WHERE attempted_at < NOW() - INTERVAL 24 HOUR");
}
}
// Rate limit by both email and IP separately
// This prevents an attacker from bypassing IP limits by trying thousands of usernames
$emailIdentifier = 'email:' . strtolower($email);
$ipIdentifier = 'ip:' . $_SERVER['REMOTE_ADDR'];
if (!checkLoginRateLimit($emailIdentifier, $db) || !checkLoginRateLimit($ipIdentifier, $db)) {
http_response_code(429);
echo json_encode(['error' => 'Too many login attempts. Please try again in 15 minutes.']);
exit;
}
Forcing Password Reset After Breach
When you learn that credentials from your service (or from other services, given credential stuffing) have been compromised, you need a mechanism to force password resets for affected accounts. The pattern:
<?php
// Add a column to your users table: force_password_reset TINYINT(1) DEFAULT 0
// Also add: password_reset_token VARCHAR(64), reset_token_expires DATETIME
function initiatePasswordReset(int $userId, PDO $db): string {
$token = bin2hex(random_bytes(32)); // 64 character hex string
$expiry = date('Y-m-d H:i:s', time() + 3600); // 1 hour expiry
$stmt = $db->prepare(
"UPDATE users
SET password_reset_token = ?, reset_token_expires = ?
WHERE id = ?"
);
$stmt->execute([hash('sha256', $token), $expiry, $userId]); // Store hash, not raw token
return $token; // Return raw token to send in email
}
function validateResetToken(string $token, PDO $db): ?array {
$tokenHash = hash('sha256', $token);
$stmt = $db->prepare(
"SELECT id, email FROM users
WHERE password_reset_token = ?
AND reset_token_expires > NOW()
AND is_active = 1"
);
$stmt->execute([$tokenHash]);
return $stmt->fetch() ?: null;
}
// Send reset email with raw token in the link
// The link: https://yoursite.com/reset-password?token=RAW_TOKEN
// When user arrives, validate token hash, show form, process new password
The Move to Passwordless Authentication
The long-term trend in authentication is away from passwords entirely, toward WebAuthn/FIDO2 (hardware security keys and passkeys), magic links (one-time email links), and app-based authentication. For developer-facing tools like JSHook, these aren't just theoretical improvements — they meaningfully reduce the risk surface.
Passkeys (the consumer-friendly name for WebAuthn credentials stored on device) are now supported across all major browsers and platforms, and the user experience is comparable to biometric login: a fingerprint or face scan instead of typing a password. They're phishing-resistant by design (the credential is bound to the origin it was created for) and breach-resistant (no password hash to steal).
If you're building new authentication today, it's worth evaluating whether passwordless can be your primary flow from the start rather than being added later as an alternative.
— Skand K.