A login that “works” is not a login that is safe. This lesson collects the habits that matter, with the reasons.
1. Hash passwords properly
password_hash() with PASSWORD_DEFAULT, password_verify() to check, password_needs_rehash() to upgrade. See Password Hashing.
2. Limit login attempts
Without a limit, an attacker can try thousands of passwords against one account, or one common password against thousands of accounts. Count failures and slow them down.
<?php
function loginIsBlocked(array $failures, int $now, int $max = 5, int $window = 900): bool
{
$recent = array_filter($failures, fn(int $time) => $now - $time < $window);
return count($recent) >= $max;
}
$now = 10000;
var_dump(loginIsBlocked([9900, 9910, 9920], $now));
var_dump(loginIsBlocked([9900, 9910, 9920, 9930, 9940], $now));
var_dump(loginIsBlocked([1000, 1010, 1020, 1030, 1040, 1050], $now)); // old failures
bool(false)
bool(true)
bool(false)
Store the failures in the database (or a cache such as Redis) by account and by IP address, and block for a time after too many. Be careful: locking an account for a long time lets an attacker lock out real users on purpose. A short, growing delay is friendlier than a permanent lock.
3. One generic error message
“Email or password is incorrect.” Never “unknown email” or “wrong password”. Make the timing equal, too (see password_verify()).
4. A sensible password policy
- A minimum length of at least 8, better 10 to 12.
- Allow long passphrases and all characters, including spaces.
- Do not force composition rules (“one capital, one symbol”). They lead to
Password1!. - Do not require regular password changes without a reason.
- Check against lists of known leaked passwords (the Have I Been Pwned “range” API uses a k-anonymity method that never sends the full password).
- Let users use a password manager: correct
autocompleteattributes, no paste blocking.
5. HTTPS only
Without HTTPS, passwords and session cookies travel in the clear. Redirect HTTP to HTTPS, set the secure flag on cookies, and consider the HSTS header (see Security Headers).
6. Safe sessions
New session ID at login, secure cookie flags, idle and absolute timeouts, destroy on logout. See Secure Session Practices.
7. CSRF protection on every state-changing form
Login, logout, password change, delete. See CSRF.
8. Two-factor authentication (2FA)
A second proof, such as a code from an authenticator app (TOTP), protects an account even if the password leaks. Use a maintained library for TOTP rather than writing it yourself. Offer backup codes so a lost phone does not lock users out forever.
9. Protect sensitive actions
For changing the email or password, or deleting the account, ask for the current password again, even when the user is logged in.
10. Log security events, safely
Log failed logins, password changes and resets with the time, the user id and the IP address. Never log passwords or tokens. Review the log, or alert on unusual patterns.
<?php
function securityLog(string $event, array $context = []): string
{
unset($context["password"], $context["token"]);
return date("Y-m-d") . " " . $event . " " . json_encode($context);
}
echo securityLog("login_failed", ["email" => "ana@example.com", "password" => "oops", "ip" => "203.0.113.9"]);
2026-10-05 login_failed {"email":"ana@example.com","ip":"203.0.113.9"}
(The date will show today’s date.)
11. Keep secrets out of code
Database passwords, API keys and mail credentials come from environment variables or a config file outside the web root.
12. Update everything
An outdated PHP version, framework or plugin is how many sites get hacked. Update regularly and run composer audit.
Quick checklist
- [ ]
password_hash()/password_verify() - [ ] Rate limiting and slow-down on login, reset and registration
- [ ] One generic error message, equal timing
- [ ] HTTPS, secure and HttpOnly cookies
- [ ] New session id at login, timeouts, full logout
- [ ] CSRF tokens
- [ ] Reset tokens: random, hashed, expiring, one-use
- [ ] 2FA offered for important accounts
- [ ] Logs without secrets
- [ ] Updates