Programming Beginner 7 min

How to Hash Passwords Correctly in PHP

Storing passwords incorrectly is one of the most consequential mistakes a developer can make. A database breach with plain-text or weakly-hashed passwords exposes your users immediately and permanently. The correct approach has been standardised in PHP since version 5.5 — there is no reason to improvise.

This guide covers the PHP built-ins you should use, why MD5 and SHA1 are disqualified, how to verify passwords without timing attacks, and how to upgrade hashes transparently when the algorithm changes.

Step-by-step

  1. 1

    Never Store Plain Text or Weak Hashes

    Plain text is obviously catastrophic. MD5 and SHA1 are cryptographic hash functions, not password hashing algorithms — they are fast by design, which is exactly what makes them dangerous for passwords. An attacker with a GPU can test billions of MD5 hashes per second. Precomputed rainbow tables cover every common password. If you use MD5 or SHA1 for passwords, you are not hashing passwords — you are delaying an inevitable breach by hours.

    SHA256 and SHA512 have the same problem. Speed is the enemy of password hashing.

  2. 2

    Hash with password_hash()

    password_hash() is the only function you need for creating password hashes. It generates a cryptographically random salt automatically and embeds it in the output — you do not manage salts manually. Use PASSWORD_BCRYPT as the default, or PASSWORD_ARGON2ID if your server has the Argon2 extension and you want stronger memory-hard hashing.

    php
    <?php
    
    $password = 'user_supplied_password';
    
    // Bcrypt — the safe default (cost 10 by default)
    $hash = password_hash($password, PASSWORD_BCRYPT);
    
    // Argon2id — stronger, preferred if available
    $hash = password_hash($password, PASSWORD_ARGON2ID);
    
    // The hash includes algorithm, cost, salt, and hash — store this entire string
    // Example output: $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
    echo $hash;
  3. 3

    Tune the Cost Factor

    The cost factor controls how much CPU work bcrypt does. Higher cost = slower hashing = harder to brute-force. The default is 10. Aim for a hash time of 200–500ms on your production hardware — run the benchmark below and pick the highest cost that stays under that threshold. Re-tune every few years as hardware gets faster.

    php
    <?php
    
    // Benchmark: find the right cost for your server
    $targetMs = 300; // 300ms target
    $cost = 9;
    do {
        $cost++;
        $start = microtime(true);
        password_hash('benchmark', PASSWORD_BCRYPT, ['cost' => $cost]);
        $ms = (microtime(true) - $start) * 1000;
    } while ($ms < $targetMs);
    
    echo "Use cost: " . $cost . " ({$ms}ms)";
    
    // Use the chosen cost when hashing real passwords
    $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]);
  4. 4

    Verify with password_verify()

    password_verify() extracts the algorithm, cost, and salt from the stored hash and recomputes it against the provided password. It returns true or false. Critically, it uses a constant-time comparison internally — there is no timing side-channel. Do not use === or strcmp() to compare password hashes; they are not constant-time.

    php
    <?php
    
    $storedHash = '\$2y\$10\$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
    
    $isValid = password_verify($userInput, $storedHash);
    
    if ($isValid) {
        // Grant access
    } else {
        // Reject — do NOT reveal whether the email or password was wrong
    }
  5. 5

    Upgrade Hashes Transparently

    password_needs_rehash() checks whether a stored hash was created with a different algorithm or cost factor than what you currently use. Call it after a successful login — you already have the plain-text password in hand at that moment. If it returns true, rehash and update the database silently. The user never knows, and all hashes gradually migrate to the new standard.

    php
    <?php
    
    function loginUser(string $email, string $plainPassword): bool
    {
        $user = User::findByEmail($email);
        if (! $user) return false;
    
        if (! password_verify($plainPassword, $user->password)) {
            return false;
        }
    
        // Transparently upgrade if algorithm or cost changed
        if (password_needs_rehash($user->password, PASSWORD_BCRYPT, ['cost' => 12])) {
            $user->password = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
            $user->save();
        }
    
        return true;
    }
  6. 6

    Use the Laravel Hash Facade

    If you are using Laravel, do not call password_hash() directly. Use Hash::make() and Hash::check() — they wrap the same PHP functions but respect your config/hashing.php settings (driver, cost, memory limits) and make it easy to swap the algorithm application-wide without touching every usage site.

    php
    <?php
    
    use Illuminate\Support\Facades\Hash;
    
    // Storing a password
    $user->password = Hash::make($request->password);
    $user->save();
    
    // Verifying at login
    if (! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['These credentials do not match our records.'],
        ]);
    }
    
    // Check if rehash is needed (Laravel handles this automatically
    // in the built-in LoginController, but manual check looks like this)
    if (Hash::needsRehash($user->password)) {
        $user->password = Hash::make($plainPassword);
        $user->save();
    }
  7. 7

    Understand Why Peppers Are Rarely Necessary

    A pepper is a secret string appended to the password before hashing, stored in application config (not the database). The idea is that even if the database is stolen, the attacker cannot crack hashes without the pepper. In practice, strong bcrypt or Argon2id hashes are already computationally infeasible to crack — peppers add operational complexity (rotation, storage) for marginal security gain. Use a pepper only if your threat model specifically includes database-only breaches combined with a need to handle weak user passwords gracefully.

Tips & gotchas

  • Always store the full output of <code>password_hash()</code> — it includes the algorithm, cost, and salt. Never store just the hash portion. Column type: <code>VARCHAR(255)</code>.
  • Increase the bcrypt cost when you upgrade servers. A cost that takes 250ms on a 2020 server may take 80ms on a 2025 server — that is 3x easier to brute-force.
  • Do not limit password length in a way that reveals the hash algorithm. Bcrypt silently truncates at 72 bytes — if you need longer passwords, pre-hash with SHA384 (not SHA256 to avoid length extension) before passing to bcrypt.
  • For legacy systems still using MD5, the migration path is: on next login verify the MD5 hash, then immediately rehash with bcrypt and update the column. Old MD5 hashes are replaced one by one as users log in.
  • Constant-time comparison matters for token comparison too. Use <code>hash_equals(\$knownHash, \$userHash)</code> anywhere you compare security-sensitive strings outside of <code>password_verify()</code>.

Wrapping up

Password hashing in PHP is solved: password_hash() with PASSWORD_BCRYPT or PASSWORD_ARGON2ID, verify with password_verify(), and upgrade silently with password_needs_rehash(). There is no valid reason to reach for anything else.

#PHP #Security #Auth
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.