Securely Storing User Passwords with PHP's password_hash API
Learn how to use PHP's password_hash API to store passwords safely, verify them, and trigger upgrades while respecting trust boundaries and operational checks.
11 Jul 2026, 18:17 UTC

Requirements
When storing user credentials the system must:
- Never keep the plaintext password longer than necessary.
- Resist brute‑force and rainbow‑table attacks.
- Allow the hashing algorithm or work factor to be upgraded without invalidating existing hashes.
- Keep verification latency within an acceptable bound for the expected login rate.
Smallest Suitable Design
Use PHP’s built‑in password hashing API, which bundles algorithm, cost, salt and hash into a single string.
- Generate a hash at registration or password change:
- Verify a login attempt:
// $password comes from the request (plaintext, never logged)
$hash = password_hash($password, PASSWORD_DEFAULT);
// Store $hash in a VARCHAR column (e.g., 255 chars) of the users table
// $inputPassword is the plaintext supplied by the user
if (password_verify($inputPassword, $storedHash)) {
// credentials match
} else {
// credentials do not match
}
The function password_hash uses PASSWORD_DEFAULT, which currently maps to bcrypt ($2y$) on PHP ≥5.5 and to Argon2id ($argon2id$) on PHP ≥7.3. The resulting string contains all parameters needed for verification, so no separate salt column is required.
Trust and Data Boundaries
- Plaintext password lives only in the request memory (e.g., PHP superglobal
$_POST) and is destroyed after the hash is created or verified. - Hash never leaves the storage layer; the application never logs, echoes, or transmits it.
- Database access for the
userstable is restricted to a dedicated service account with SELECT and UPDATE privileges only.
Operational Checks
After a successful verification, ask whether the stored hash needs to be regenerated with the current default settings:
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash($inputPassword, PASSWORD_DEFAULT);
// UPDATE users SET password_hash = $newHash WHERE id = $userId;
}
This check should run on every successful login; the re‑hash can be performed lazily (during the same request) or queued for a background job if latency is a concern.
Failure Modes
- Using a weak or custom algorithm (e.g., md5, sha1) removes the built‑in salt and constant‑time comparison, exposing credentials to rainbow‑table and timing attacks.
- Storing the plaintext password, even temporarily in logs or debug output, defeats the purpose of hashing.
- Disabling the constant‑time comparison in
password_verify(by modifying the hash string) can lead to side‑channel leakage. - Running on PHP <5.5 without a compatibility library means
password_hashis unavailable, forcing developers to implement their own (error‑prone) solution.
Conditions That Would Change the Design
- Legacy PHP (<5.5): Install a compatible library such as password_compat and continue using the same API calls.
- Migrating from legacy md5/sha1 hashes: Keep both the old hash and the new password_hash column; during login, first try
password_verify; if it fails, verify against the legacy hash and, on success, replace it with a freshpassword_hash. - Regulatory mandate for Argon2id: Ensure the runtime is PHP ≥7.3; optionally force the algorithm with
password_hash($password, PASSWORD_ARGON2ID)and adjust the memory and parallelism parameters via the options array. - High‑traffic systems: Benchmark login latency under expected load; if the default cost (10 for bcrypt) causes excessive CPU usage, lower the cost temporarily, but document the security trade‑off and plan a gradual increase as capacity allows.
Practical Verification Steps
- Confirm the PHP version meets the minimum:
php -r 'echo phpversion();'should return ≥5.5 (or ≥7.3 if Argon2id is required). - Generate a test hash and inspect its format:
- Store the hash in a test VARCHAR column, then verify:
- Check re‑hash detection:
- Review application logging configuration to ensure
error_logorvar_dumpstatements never contain the hash or plaintext password. Use a log sanitizer or seterror_reportingto exclude notices that might inadvertently expose data.
php -r "echo password_hash('test', PASSWORD_DEFAULT);"
The output should start with $2y$ (bcrypt) or $argon2id$ (Argon2id).
php -r "
$hash = password_hash('test', PASSWORD_DEFAULT);
var_dump(password_verify('test', $hash)); // bool(true)
var_dump(password_verify('wrong', $hash)); // bool(false)
"
php -r "
$hash = password_hash('test', PASSWORD_DEFAULT);
var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT)); // bool(false)
// Simulate an increased cost
$newHash = password_hash('test', PASSWORD_DEFAULT, ['cost' => 12]);
var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT)); // bool(true) after updating $hash to $newHash
"
Limitations and Checks
The API itself does not defend against credential stuffing or brute‑force at the network layer; those mitigations belong in authentication throttling, CAPTCHA, or multi‑factor authentication. After any change to the hashing cost or algorithm, monitor login latency and CPU usage under load to avoid accidental denial‑of‑service due to excessive computation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.