Skip to content

PHP Security in 2025: Salts, Hashing, and Safer Passwords (the right way)

Outdated tutorials still show manual salts with md5()/sha1() (or even “double hashing”). That’s not just old—it’s weak.
In modern PHP, you don’t invent your own recipe. You use password_hash() and password_verify().

Why?
password_hash() automatically generates a random per-password salt and stores algorithm + parameters + salt + hash in one string. You keep just that one string in your database—no separate salt column—and verify with password_verify().


TL;DR

  • Don’t use md5()/sha1() + custom salt.
  • Do use password_hash() + password_verify().
  • Prefer Argon2id (memory-hard); fall back to PASSWORD_DEFAULT (bcrypt today).
  • On login, call password_needs_rehash() to upgrade old hashes automatically.

What is a salt—and where did it go?

A salt is random data mixed into the password before hashing. It prevents precomputed attacks (like rainbow tables) and ensures identical passwords hash differently.

With password_hash(), you don’t manage salts yourself. PHP:

  • Generates a cryptographically secure salt,
  • Picks the right format,
  • Embeds algorithm + options + salt + digest in the returned string.

Store that string (e.g., VARCHAR(255)). Done.


Minimal (works everywhere): PASSWORD_DEFAULT

This is the easiest, future-proof starting point.

// Create & store the hash (signup / change password)
$hash = password_hash($password, PASSWORD_DEFAULT); // bcrypt today
// Save $hash in your DB (VARCHAR(255) is fine)
// Verify (login) + auto-upgrade if PHP’s default changes
if (password_verify($password, $hashFromDb)) {
    if (password_needs_rehash($hashFromDb, PASSWORD_DEFAULT)) {
        $newHash = password_hash($password, PASSWORD_DEFAULT);
        // store $newHash back to the DB for this user
    }
    // success: authenticated
} else {
    // invalid credentials
}

PASSWORD_DEFAULT may change to stronger algorithms in future PHP versions. password_needs_rehash() lets you upgrade seamlessly.

Recommended when available: Argon2id (tuned)

Argon2id is memory-hard, making large-scale cracking more expensive.

$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
$options = defined('PASSWORD_ARGON2ID')
    ? ['memory_cost' => 1 << 17, /* ~128 MB */ 'time_cost' => 4, 'threads' => 2]
    : [];

$hash = password_hash($password, $algo, $options);
if (password_verify($password, $hashFromDb)) {
    if (password_needs_rehash($hashFromDb, $algo, $options)) {
        $newHash = password_hash($password, $algo, $options);
        // persist $newHash
    }
    // success
} else {
    // invalid
}

How to choose costs:
Start with memory_cost 64–128 MB and time_cost 3–4, threads 2.
Benchmark on your server and adjust to keep sign-in under ~200–300 ms.


Optional: add a pepper (app-level secret)

A pepper is a server-side secret (not stored in the DB) you mix into the password before hashing. It’s optional defense-in-depth; store it in an env/secret manager, not in your codebase.

$peppered = hash_hmac('sha256', $password, $_ENV['PASSWORD_PEPPER'], true);
$hash = password_hash($peppered, $algo, $options);

// Verify
if (password_verify(hash_hmac('sha256', $password, $_ENV['PASSWORD_PEPPER'], true), $hashFromDb)) {
    // ...
}

If you use a pepper, plan how you’d rotate it (e.g., accept old + new pepper during a transition).


Migrating legacy hashes (md5/sha1 + salt)

Have old rows like sha256($salt.$password) or md5($salt.$password)?
Upgrade on login without forcing resets:

// 1) Try modern hash first
if (password_verify($password, $user['password_hash'])) {
    // maybe rehash...
    // OK
} else {
    // 2) Try legacy check, then upgrade
    $legacyOk = false;
    if (!empty($user['legacy_salt']) && !empty($user['legacy_hash'])) {
        $calc = hash('sha256', $user['legacy_salt'] . $password);
        $legacyOk = hash_equals($user['legacy_hash'], $calc);
    }

    if ($legacyOk) {
        // Upgrade to modern hash
        $newHash = password_hash($password, $algo, $options);
        // UPDATE users SET password_hash=?, legacy_salt=NULL, legacy_hash=NULL WHERE id=?
        // proceed as logged in
    } else {
        // invalid
    }
}

After a sensible window (once most users have logged in), drop the legacy columns.


What to store in the database

  • Only the single string returned by password_hash() (e.g., VARCHAR(255)).
  • Do not store a separate salt column.
  • Consider password_changed_at (timestamp) for security policies.

Secure password reset (bonus)

If your article touches resets, use single-use, short-lived tokens:

  • Generate a random token: bin2hex(random_bytes(32)).
  • Store only the hash of the token in DB (hash('sha256', $token)).
  • Email the raw token in a link.
  • On submit, hash and compare with hash_equals().
  • Enforce expiry (e.g., 30 minutes) and invalidate after use.

Common pitfalls (and fixes)

  • “Where is the salt?” → Inside the hash string; PHP handles it.
  • Manual salts/double hashing → Replace with password_hash()/password_verify().
  • No auto-upgrade → Always call password_needs_rehash() after a successful login.
  • Weak cost settings → Benchmark and tune (Argon2id preferred).
  • Leaky errors → Don’t reveal which field failed; log server-side only.
  • No rate limiting → Add login throttling (e.g., IP/user delay, CAPTCHA after several failures).
  • No HTTPS → Always use TLS in production.

Quick Checklist

  • Use password_hash() + password_verify().
  • Prefer PASSWORD_ARGON2ID (with tuned options); otherwise use PASSWORD_DEFAULT (bcrypt).
  • Tune costs and benchmark on your server (e.g., Argon2id ~64–128 MB memory_cost, time_cost 3–4, threads 2).
  • After successful login, call password_needs_rehash() and update the stored hash if needed.
  • Store only the hash string (e.g., VARCHAR(255)); no separate salt column.
  • Plan migration for legacy hashes (md5/sha1+salt) and upgrade on login.
  • Consider an application “pepper” (and a rotation plan) if your threat model warrants it.
  • Add login rate limiting and always use HTTPS in production.

Conclusion

You don’t need to hand-craft salts—or chain md5()/sha1()—to “strengthen” passwords.
In 2025, the secure, maintainable approach is:

password_hash() → store result → password_verify()password_needs_rehash() when costs improve.

That gives you random salts per user, modern algorithms, and an easy upgrade path as PHP evolves—without rewriting your security every few years.

Tags:

1 thought on “PHP Security in 2025: Salts, Hashing, and Safer Passwords (the right way)”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.