Password Security in PHP
Introduction to Password Security
Password security is one of the most critical aspects of web application security. Proper password handling involves secure storage through hashing, strong validation policies, and protection against common attacks. PHP provides built-in functions for secure password handling that follow current best practices.
Password Hashing
Never Store Plain Text Passwords
<?php
// NEVER do this - plain text storage
$password = $_POST['password'];
$sql = "INSERT INTO users (email, password) VALUES (?, ?)";
$stmt->execute([$email, $password]); // WRONG!
?>
Using password_hash()
PHP's password_hash()
function provides secure password hashing using industry-standard algorithms:
<?php
function registerUser($email, $password) {
// Hash the password securely
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
// Store in database
$pdo = new PDO("mysql:host=localhost;dbname=myapp", $username, $dbpassword);
$stmt = $pdo->prepare("INSERT INTO users (email, password_hash) VALUES (?, ?)");
return $stmt->execute([$email, $hashedPassword]);
}
// Usage
$email = $_POST['email'];
$password = $_POST['password'];
if (registerUser($email, $password)) {
echo "User registered successfully";
}
?>
Password Hashing Options
<?php
// Use default algorithm (currently bcrypt, may change in future PHP versions)
$hash = password_hash($password, PASSWORD_DEFAULT);
// Explicitly use bcrypt
$hash = password_hash($password, PASSWORD_BCRYPT);
// Use Argon2i (PHP 7.2+)
$hash = password_hash($password, PASSWORD_ARGON2I);
// Use Argon2id (PHP 7.2+) - recommended for new applications
$hash = password_hash($password, PASSWORD_ARGON2ID);
// Custom cost for bcrypt (higher = more secure but slower)
$options = ['cost' => 12];
$hash = password_hash($password, PASSWORD_BCRYPT, $options);
// Custom options for Argon2
$options = [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 iterations
'threads' => 3, // 3 threads
];
$hash = password_hash($password, PASSWORD_ARGON2ID, $options);
?>
Password Verification
Using password_verify()
<?php
function authenticateUser($email, $password) {
$pdo = new PDO("mysql:host=localhost;dbname=myapp", $username, $dbpassword);
$stmt = $pdo->prepare("SELECT id, password_hash FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
return $user['id']; // Authentication successful
}
return false; // Authentication failed
}
// Usage
$email = $_POST['email'];
$password = $_POST['password'];
$userId = authenticateUser($email, $password);
if ($userId) {
$_SESSION['user_id'] = $userId;
echo "Login successful";
} else {
echo "Invalid credentials";
}
?>
Complete Login System
<?php
class AuthenticationService {
private $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function register($email, $password) {
// Validate password strength
if (!$this->isPasswordStrong($password)) {
throw new Exception("Password does not meet security requirements");
}
// Check if email already exists
$stmt = $this->pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
throw new Exception("Email already registered");
}
// Hash password and create user
$hashedPassword = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $this->pdo->prepare("INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, NOW())");
return $stmt->execute([$email, $hashedPassword]);
}
public function login($email, $password) {
$stmt = $this->pdo->prepare("
SELECT id, password_hash, failed_attempts, locked_until
FROM users
WHERE email = ?
");
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
$this->logFailedAttempt($email);
throw new Exception("Invalid credentials");
}
// Check if account is locked
if ($user['locked_until'] && strtotime($user['locked_until']) > time()) {
throw new Exception("Account is temporarily locked. Try again later.");
}
// Verify password
if (password_verify($password, $user['password_hash'])) {
$this->clearFailedAttempts($user['id']);
$this->logSuccessfulLogin($user['id']);
return $user['id'];
} else {
$this->recordFailedAttempt($user['id']);
throw new Exception("Invalid credentials");
}
}
private function isPasswordStrong($password) {
// Minimum 8 characters
if (strlen($password) < 8) return false;
// At least one uppercase letter
if (!preg_match('/[A-Z]/', $password)) return false;
// At least one lowercase letter
if (!preg_match('/[a-z]/', $password)) return false;
// At least one number
if (!preg_match('/[0-9]/', $password)) return false;
// At least one special character
if (!preg_match('/[^a-zA-Z0-9]/', $password)) return false;
return true;
}
private function recordFailedAttempt($userId) {
$stmt = $this->pdo->prepare("
UPDATE users
SET failed_attempts = failed_attempts + 1,
locked_until = CASE
WHEN failed_attempts >= 4 THEN DATE_ADD(NOW(), INTERVAL 15 MINUTE)
ELSE NULL
END
WHERE id = ?
");
$stmt->execute([$userId]);
}
private function clearFailedAttempts($userId) {
$stmt = $this->pdo->prepare("UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = ?");
$stmt->execute([$userId]);
}
private function logFailedAttempt($email) {
$stmt = $this->pdo->prepare("INSERT INTO login_attempts (email, success, ip_address, attempted_at) VALUES (?, 0, ?, NOW())");
$stmt->execute([$email, $_SERVER['REMOTE_ADDR']]);
}
private function logSuccessfulLogin($userId) {
$stmt = $this->pdo->prepare("INSERT INTO login_attempts (user_id, success, ip_address, attempted_at) VALUES (?, 1, ?, NOW())");
$stmt->execute([$userId, $_SERVER['REMOTE_ADDR']]);
}
}
?>
Password Rehashing
When security standards change or you want to increase hash strength, you should rehash existing passwords:
<?php
function checkAndRehashPassword($userId, $password, $currentHash) {
// Verify current password
if (!password_verify($password, $currentHash)) {
return false;
}
// Check if rehashing is needed
if (password_needs_rehash($currentHash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
// Update database with new hash
$pdo = new PDO("mysql:host=localhost;dbname=myapp", $username, $dbpassword);
$stmt = $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
$stmt->execute([$newHash, $userId]);
}
return true;
}
// Usage during login
$user = getUserFromDatabase($email);
if (checkAndRehashPassword($user['id'], $password, $user['password_hash'])) {
// User authenticated and password rehashed if necessary
startUserSession($user['id']);
}
?>
Password Strength Validation
Comprehensive Password Validation
<?php
class PasswordValidator {
private $minLength = 8;
private $maxLength = 128;
public function validate($password) {
$errors = [];
// Length checks
if (strlen($password) < $this->minLength) {
$errors[] = "Password must be at least {$this->minLength} characters long";
}
if (strlen($password) > $this->maxLength) {
$errors[] = "Password must not exceed {$this->maxLength} characters";
}
// Character type requirements
if (!preg_match('/[a-z]/', $password)) {
$errors[] = "Password must contain at least one lowercase letter";
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = "Password must contain at least one uppercase letter";
}
if (!preg_match('/[0-9]/', $password)) {
$errors[] = "Password must contain at least one number";
}
if (!preg_match('/[^a-zA-Z0-9]/', $password)) {
$errors[] = "Password must contain at least one special character";
}
// Check for common patterns
if ($this->hasCommonPatterns($password)) {
$errors[] = "Password contains common patterns";
}
// Check against common passwords
if ($this->isCommonPassword($password)) {
$errors[] = "Password is too common";
}
return [
'valid' => empty($errors),
'errors' => $errors,
'strength' => $this->calculateStrength($password)
];
}
private function hasCommonPatterns($password) {
$patterns = [
'/(.)\1{2,}/', // Three or more consecutive identical characters
'/123|abc|qwe/i', // Sequential characters
'/password|admin/i', // Common words
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $password)) {
return true;
}
}
return false;
}
private function isCommonPassword($password) {
$commonPasswords = [
'password', '123456', 'password123', 'admin', 'letmein',
'welcome', 'monkey', '1234567890', 'qwerty', 'abc123'
];
return in_array(strtolower($password), $commonPasswords);
}
private function calculateStrength($password) {
$score = 0;
// Length bonus
$score += min(strlen($password) * 2, 50);
// Character variety
if (preg_match('/[a-z]/', $password)) $score += 10;
if (preg_match('/[A-Z]/', $password)) $score += 10;
if (preg_match('/[0-9]/', $password)) $score += 10;
if (preg_match('/[^a-zA-Z0-9]/', $password)) $score += 15;
// Entropy bonus for mixed character types
$types = 0;
if (preg_match('/[a-z]/', $password)) $types++;
if (preg_match('/[A-Z]/', $password)) $types++;
if (preg_match('/[0-9]/', $password)) $types++;
if (preg_match('/[^a-zA-Z0-9]/', $password)) $types++;
$score += $types * 5;
// Penalty for common patterns
if ($this->hasCommonPatterns($password)) {
$score -= 20;
}
if ($score < 30) return 'weak';
if ($score < 60) return 'fair';
if ($score < 80) return 'good';
return 'strong';
}
}
// Usage
$validator = new PasswordValidator();
$result = $validator->validate($_POST['password']);
if (!$result['valid']) {
foreach ($result['errors'] as $error) {
echo "Error: $error\n";
}
} else {
echo "Password strength: " . $result['strength'];
}
?>
Password Reset Security
Secure Password Reset Implementation
<?php
class PasswordResetService {
private $pdo;
private $tokenExpiry = 3600; // 1 hour
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function initiateReset($email) {
// Check if user exists
$stmt = $this->pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user) {
// Don't reveal if email exists or not
return true;
}
// Generate secure token
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + $this->tokenExpiry);
// Store reset token
$stmt = $this->pdo->prepare("
INSERT INTO password_resets (user_id, token, expires_at, created_at)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
token = VALUES(token),
expires_at = VALUES(expires_at),
used_at = NULL
");
$stmt->execute([$user['id'], hash('sha256', $token), $expiresAt]);
// Send email (implement your email service)
$this->sendResetEmail($email, $token);
return true;
}
public function resetPassword($token, $newPassword) {
// Hash the token for lookup
$hashedToken = hash('sha256', $token);
// Find valid reset request
$stmt = $this->pdo->prepare("
SELECT pr.user_id, pr.expires_at, u.email
FROM password_resets pr
JOIN users u ON pr.user_id = u.id
WHERE pr.token = ?
AND pr.expires_at > NOW()
AND pr.used_at IS NULL
");
$stmt->execute([$hashedToken]);
$reset = $stmt->fetch();
if (!$reset) {
throw new Exception("Invalid or expired reset token");
}
// Validate new password
$validator = new PasswordValidator();
$validation = $validator->validate($newPassword);
if (!$validation['valid']) {
throw new Exception("Password validation failed: " . implode(', ', $validation['errors']));
}
// Update password
$this->pdo->beginTransaction();
try {
// Hash new password
$hashedPassword = password_hash($newPassword, PASSWORD_ARGON2ID);
// Update user password
$stmt = $this->pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
$stmt->execute([$hashedPassword, $reset['user_id']]);
// Mark reset token as used
$stmt = $this->pdo->prepare("UPDATE password_resets SET used_at = NOW() WHERE token = ?");
$stmt->execute([$hashedToken]);
// Invalidate all other tokens for this user
$stmt = $this->pdo->prepare("DELETE FROM password_resets WHERE user_id = ? AND token != ?");
$stmt->execute([$reset['user_id'], $hashedToken]);
$this->pdo->commit();
// Log password change
$this->logPasswordChange($reset['user_id']);
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
private function sendResetEmail($email, $token) {
$resetUrl = "https://yoursite.com/reset-password?token=" . urlencode($token);
$subject = "Password Reset Request";
$message = "
A password reset was requested for your account.
Click the link below to reset your password:
$resetUrl
This link will expire in 1 hour.
If you did not request this reset, please ignore this email.
";
// Use your preferred email service
mail($email, $subject, $message);
}
private function logPasswordChange($userId) {
$stmt = $this->pdo->prepare("
INSERT INTO security_events (user_id, event_type, ip_address, created_at)
VALUES (?, 'password_changed', ?, NOW())
");
$stmt->execute([$userId, $_SERVER['REMOTE_ADDR']]);
}
}
?>
Two-Factor Authentication Integration
Adding 2FA to Password Authentication
<?php
class TwoFactorAuth {
private $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function setupTwoFactor($userId) {
// Generate secret key
$secret = $this->generateSecret();
// Store secret
$stmt = $this->pdo->prepare("
UPDATE users
SET two_factor_secret = ?, two_factor_enabled = 0
WHERE id = ?
");
$stmt->execute([$secret, $userId]);
return [
'secret' => $secret,
'qr_code_url' => $this->generateQRCode($secret)
];
}
public function verifyAndEnable($userId, $code) {
$stmt = $this->pdo->prepare("SELECT two_factor_secret FROM users WHERE id = ?");
$stmt->execute([$userId]);
$secret = $stmt->fetchColumn();
if ($this->verifyTOTP($secret, $code)) {
// Enable 2FA
$stmt = $this->pdo->prepare("UPDATE users SET two_factor_enabled = 1 WHERE id = ?");
$stmt->execute([$userId]);
// Generate backup codes
$backupCodes = $this->generateBackupCodes($userId);
return ['success' => true, 'backup_codes' => $backupCodes];
}
return ['success' => false];
}
public function verifyLogin($userId, $password, $totpCode = null) {
// First verify password
$stmt = $this->pdo->prepare("SELECT password_hash, two_factor_enabled, two_factor_secret FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!password_verify($password, $user['password_hash'])) {
return false;
}
// If 2FA is enabled, verify TOTP
if ($user['two_factor_enabled']) {
if (!$totpCode) {
throw new Exception("2FA code required");
}
if (!$this->verifyTOTP($user['two_factor_secret'], $totpCode)) {
// Check backup codes as fallback
if (!$this->verifyBackupCode($userId, $totpCode)) {
return false;
}
}
}
return true;
}
private function generateSecret($length = 32) {
return bin2hex(random_bytes($length));
}
private function verifyTOTP($secret, $code) {
// Simple TOTP implementation (use a library like sonata-project/GoogleAuthenticator for production)
$timeStep = floor(time() / 30);
for ($i = -1; $i <= 1; $i++) { // Allow ±30 seconds tolerance
$expectedCode = $this->generateTOTP($secret, $timeStep + $i);
if ($code === $expectedCode) {
return true;
}
}
return false;
}
private function generateTOTP($secret, $timeStep) {
// Simplified TOTP - use proper library in production
$data = pack('N*', 0, $timeStep);
$hash = hash_hmac('sha1', $data, hex2bin($secret), true);
$offset = ord($hash[19]) & 0xf;
$code = (
((ord($hash[$offset]) & 0x7f) << 24) |
((ord($hash[$offset + 1]) & 0xff) << 16) |
((ord($hash[$offset + 2]) & 0xff) << 8) |
(ord($hash[$offset + 3]) & 0xff)
) % 1000000;
return sprintf('%06d', $code);
}
private function generateBackupCodes($userId) {
$codes = [];
for ($i = 0; $i < 10; $i++) {
$codes[] = bin2hex(random_bytes(4));
}
// Store hashed backup codes
$stmt = $this->pdo->prepare("DELETE FROM backup_codes WHERE user_id = ?");
$stmt->execute([$userId]);
$stmt = $this->pdo->prepare("INSERT INTO backup_codes (user_id, code_hash, created_at) VALUES (?, ?, NOW())");
foreach ($codes as $code) {
$stmt->execute([$userId, password_hash($code, PASSWORD_DEFAULT)]);
}
return $codes;
}
private function verifyBackupCode($userId, $code) {
$stmt = $this->pdo->prepare("SELECT id, code_hash FROM backup_codes WHERE user_id = ? AND used_at IS NULL");
$stmt->execute([$userId]);
while ($backupCode = $stmt->fetch()) {
if (password_verify($code, $backupCode['code_hash'])) {
// Mark code as used
$useStmt = $this->pdo->prepare("UPDATE backup_codes SET used_at = NOW() WHERE id = ?");
$useStmt->execute([$backupCode['id']]);
return true;
}
}
return false;
}
}
?>
Security Best Practices
1. Use Strong Hashing Algorithms
<?php
// Good - Use modern algorithms
$hash = password_hash($password, PASSWORD_ARGON2ID);
// Avoid deprecated methods
// md5($password) - NEVER use
// sha1($password) - NEVER use
// hash('sha256', $password) - Not sufficient without salt
?>
2. Implement Rate Limiting
<?php
class LoginRateLimiter {
private $pdo;
private $maxAttempts = 5;
private $lockoutDuration = 300; // 5 minutes
public function checkRateLimit($identifier) {
$stmt = $this->pdo->prepare("
SELECT COUNT(*) as attempts, MAX(attempted_at) as last_attempt
FROM failed_login_attempts
WHERE identifier = ?
AND attempted_at > DATE_SUB(NOW(), INTERVAL ? SECOND)
");
$stmt->execute([$identifier, $this->lockoutDuration]);
$result = $stmt->fetch();
if ($result['attempts'] >= $this->maxAttempts) {
$remainingTime = $this->lockoutDuration - (time() - strtotime($result['last_attempt']));
throw new Exception("Too many failed attempts. Try again in $remainingTime seconds.");
}
return true;
}
public function recordFailedAttempt($identifier) {
$stmt = $this->pdo->prepare("
INSERT INTO failed_login_attempts (identifier, attempted_at)
VALUES (?, NOW())
");
$stmt->execute([$identifier]);
}
public function clearFailedAttempts($identifier) {
$stmt = $this->pdo->prepare("DELETE FROM failed_login_attempts WHERE identifier = ?");
$stmt->execute([$identifier]);
}
}
?>
3. Secure Session Management
<?php
class SecureSessionManager {
public function startSecureSession() {
// Configure secure session settings
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', 'Strict');
session_start();
// Regenerate session ID on login
if (!isset($_SESSION['initiated'])) {
session_regenerate_id(true);
$_SESSION['initiated'] = true;
}
}
public function login($userId) {
$this->startSecureSession();
// Regenerate session ID to prevent session fixation
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
$_SESSION['login_time'] = time();
$_SESSION['last_activity'] = time();
}
public function logout() {
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
}
public function validateSession() {
if (!isset($_SESSION['user_id'])) {
return false;
}
// Check session timeout (30 minutes of inactivity)
if (time() - $_SESSION['last_activity'] > 1800) {
$this->logout();
return false;
}
$_SESSION['last_activity'] = time();
return true;
}
}
?>
Password security is fundamental to application security. Always use PHP's built-in password hashing functions, implement strong validation policies, and consider additional security measures like two-factor authentication and rate limiting to protect user accounts from compromise.