A client hands you a legacy app to "modernise". You open the users table and every password column is a 32-character hex string: unsalted MD5. Two weeks later a backup bucket turns out to have been public. Anyone holding that dump can recover most of those passwords in an afternoon with one consumer GPU and a public wordlist, and because people reuse passwords, the damage does not stop at your app. Their email and bank logins are next.
Password storage is one of the few places where "fast" is a bug. The fix is not complicated: a slow, salted, memory-hard password hash, a cost you actually tuned, and a login flow that silently upgrades old hashes. Here is each piece in PHP 8.4 and Laravel 13, plus migrating an existing user base.
Why MD5, SHA-1 and SHA-256 Are the Wrong Tool
General-purpose hashes are designed to be fast. That is exactly what you want for checksums and HMACs, and exactly what an attacker wants when guessing passwords offline. Once the hashes leak, the attacker no longer talks to your rate limiter or your lockout policy. They run guesses on their own hardware as fast as the algorithm allows: tens of billions of MD5 guesses per second on one GPU, versus thousands for bcrypt.
Adding a salt (sha256($salt . $password)) stops precomputed rainbow tables and forces per-user work, but it does nothing about raw speed. Running SHA-256 a few thousand times in a loop is better, and that is basically what PBKDF2 does, but GPUs still parallelise it beautifully because it needs almost no memory.
A password hash has three jobs: a unique random salt per password, a tunable work factor so you can keep up with hardware, and ideally memory hardness so GPUs and ASICs cannot run thousands of guesses in parallel cheaply. bcrypt covers the first two. Argon2id covers all three.
bcrypt vs Argon2id
bcrypt (1999) is the boring, battle-tested default. It is PASSWORD_DEFAULT in PHP and the default driver in Laravel. Its work factor is a cost exponent: cost 12 means 2^12 rounds of the expensive key setup, and every +1 doubles the time. PHP 8.4 raised the default bcrypt cost from 10 to 12, and Laravel's BCRYPT_ROUNDS default is also 12. bcrypt uses about 4 KiB of memory, which is small enough that GPUs can still attack it, just slowly.
bcrypt has one sharp edge: it only uses the first 72 bytes of the input. Two passphrases that share the first 72 bytes produce the same hash. For normal passwords that does not matter. It does matter if you pre-concatenate things such as a username or a long pepper in front of the password, and it matters for very long UTF-8 passphrases where 72 bytes can be fewer than 30 characters.
Argon2id won the Password Hashing Competition in 2015 and is the current OWASP first choice. You tune three knobs: memory (memory_cost, in KiB), iterations (time_cost) and parallelism (threads). Because every guess needs its own block of memory, an attacker with a GPU runs out of memory bandwidth long before running out of cores. It has no 72-byte limit.
The practical rule: for a new project where you control the servers, Argon2id with at least 19 MiB of memory (OWASP's minimum) is the better choice, and 64 MiB is a sensible default if your login traffic allows it. bcrypt at cost 12 or higher is still perfectly acceptable, and much better than anything fast. Do not use plain PBKDF2 unless a compliance regime such as FIPS forces it, and then use a high iteration count (OWASP suggests 600,000 for PBKDF2-HMAC-SHA256).
The Verify-and-Rehash Flow
The key idea that makes upgrades painless: you can only re-hash a password when you have the plaintext, and the only time you have it is during a successful login. So every successful login is a chance to check whether the stored hash still matches your current policy and replace it if not.
The legacy branch handles hashes from before your migration. The normal branch handles cost changes and algorithm changes (bcrypt to Argon2id) with exactly the same code, because the hash string itself records the algorithm and its parameters.
Doing It in Plain PHP 8.4
PHP's password API does the heavy lifting. Never call crypt() directly, never generate your own salt, and never compare hashes with ===.
// Registration: let PHP pick the algorithm, salt and encoding.
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// Store $hash in a VARCHAR(255) column. It looks like:
// $2y$12$<22 chars of salt><31 chars of hash>
// Login: never compare hashes yourself.
if (password_verify($plainPassword, $hash)) {
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// persist the new $hash for this user
}
// log the user in
}
password_needs_rehash() returns true if the stored hash uses a different algorithm or different options than the ones you pass. Raise the cost, deploy, and every user is upgraded the next time they log in.
How high should the cost be? The honest answer is "as high as your login latency and CPU budget allow". A good target is roughly 250 to 500 ms per hash on your production hardware. Measure instead of guessing:
// Run this on production-like hardware, not on your laptop.
$timeTarget = 0.25; // seconds per hash you are willing to spend
$cost = 9;
do {
$cost++;
$start = microtime(true);
password_hash('benchmark-password', PASSWORD_BCRYPT, ['cost' => $cost]);
$elapsed = microtime(true) - $start;
} while ($elapsed < $timeTarget && $cost < 16);
printf("Use cost %d (%.3f s per hash)%s", $cost, $elapsed, PHP_EOL);
Pair a high cost with rate limiting (Laravel's RateLimiter or throttle middleware) so password hashing does not become a cheap way to burn your CPU.
Argon2id goes through the same API:
if (! defined('PASSWORD_ARGON2ID')) {
throw new RuntimeException('PHP was built without Argon2 support.');
}
$options = [
'memory_cost' => 65536, // KiB, so 64 MiB per hash
'time_cost' => 4, // passes over that memory
'threads' => 1, // libsodium builds only support 1
];
$hash = password_hash($plainPassword, PASSWORD_ARGON2ID, $options);
// $argon2id$v=19$m=65536,t=4,p=1$<salt>$<hash>
$valid = password_verify($plainPassword, $hash);
$stale = password_needs_rehash($hash, PASSWORD_ARGON2ID, $options);
PASSWORD_ARGON2ID only exists if PHP was compiled with libargon2 or libsodium. Official Docker images and most distro packages have it, but check in CI rather than finding out in production. Memory cost is per hash, so 64 MiB times 50 concurrent logins is 3.2 GiB. Size your PHP-FPM workers accordingly.
Laravel 13: Hash Facade, Config and Rehash on Login
Laravel wraps the same functions behind the Hash facade and reads its policy from config/hashing.php. Publish it with php artisan config:publish hashing if your app does not have it yet.
// config/hashing.php (trimmed)
return [
'driver' => env('HASH_DRIVER', 'bcrypt'), // or 'argon2id'
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => env('HASH_VERIFY', true),
],
'argon' => [
'memory' => env('ARGON_MEMORY', 65536),
'threads' => env('ARGON_THREADS', 1),
'time' => env('ARGON_TIME', 4),
'verify' => env('HASH_VERIFY', true),
],
'rehash_on_login' => true,
];
Switching HASH_DRIVER to argon2id does not break existing users: Laravel can still verify old bcrypt hashes, and rehash_on_login upgrades them as people log in.
The default User model casts password to hashed, so assigning a plaintext value hashes it automatically and assigning an existing hash leaves it alone. Since Laravel 11, Auth::attempt() calls the user provider's rehashPasswordIfRequired() after a successful check, so cost and driver upgrades need zero code from you. If you wrote a custom UserProvider, implement that method or the upgrade silently never happens.
What Laravel cannot do for you is migrate hashes it does not understand, like the raw MD5 column from the intro. Do it in two steps. First, a one-off command wraps every MD5 value in bcrypt, so no fast hash stays at rest even for users who never log in again:
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
final class WrapLegacyPasswords extends Command
{
protected $signature = 'passwords:wrap-legacy';
protected $description = 'Replace raw MD5 hashes with bcrypt(md5) so no fast hash stays at rest';
public function handle(): int
{
User::query()
->whereNotNull('legacy_md5')
->chunkById(500, function (Collection $users): void {
foreach ($users as $user) {
$user->forceFill([
'password' => Hash::make($user->legacy_md5),
'password_is_wrapped' => true,
'legacy_md5' => null,
])->save();
}
});
$this->info('Legacy hashes wrapped.');
return self::SUCCESS;
}
}
Then, at login, users with a wrapped hash are checked against bcrypt(md5(password)) and unwrapped into a normal hash:
namespace App\Http\Controllers\Auth;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
final class LoginController
{
public function __invoke(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$this->unwrapLegacyHash($credentials['email'], $credentials['password']);
// Auth::attempt() verifies the hash and, with rehash_on_login enabled,
// re-hashes it when the cost or algorithm in config/hashing.php changed.
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
private function unwrapLegacyHash(string $email, string $password): void
{
$user = User::query()->where('email', $email)->first();
if ($user === null || ! $user->password_is_wrapped) {
return;
}
// Stored value is bcrypt(md5(password)) from the one-time migration.
if (Hash::check(md5($password), $user->password)) {
$user->forceFill([
'password' => $password, // the 'hashed' cast hashes it
'password_is_wrapped' => false,
])->save();
}
}
}
Testing the Upgrade Path
Test the behaviour that matters: users can still log in after a migration, the stored hash actually changes, and failed logins change nothing. Keep tests fast with a low cost: the Laravel skeleton's phpunit.xml already sets BCRYPT_ROUNDS=4.
use App\Models\User;
use Illuminate\Support\Facades\Hash;
it('unwraps a legacy md5 password on the first successful login', function () {
$user = User::factory()->create([
'password' => Hash::make(md5('old-secret-123')),
'password_is_wrapped' => true,
]);
$this->post('/login', ['email' => $user->email, 'password' => 'old-secret-123'])
->assertRedirect('/dashboard');
$user->refresh();
expect($user->password_is_wrapped)->toBeFalse()
->and(Hash::check('old-secret-123', $user->password))->toBeTrue()
->and(Hash::check(md5('old-secret-123'), $user->password))->toBeFalse();
$this->assertAuthenticatedAs($user);
});
it('rehashes on login after the bcrypt cost goes up', function () {
$user = User::factory()->create([
'password' => password_hash('secret-pass-1', PASSWORD_BCRYPT, ['cost' => 4]),
]);
config(['hashing.bcrypt.rounds' => 5]);
Hash::forgetDrivers();
$this->post('/login', ['email' => $user->email, 'password' => 'secret-pass-1'])
->assertRedirect('/dashboard');
expect(Hash::info($user->refresh()->password)['options']['cost'])->toBe(5);
});
Hash::forgetDrivers() clears the cached hasher so the new rounds value is picked up within the same test. The second test is the one that protects you from a custom provider quietly breaking rehash-on-login.
Common Pitfalls
- Comparing hashes with
==or===. Usepassword_verify()orHash::check(). For legacy MD5 comparisons, usehash_equals()so the comparison is constant-time, and never use==, which treats strings like"0e123..."as numbers. - Too-short columns. bcrypt is 60 characters and Argon2id hashes are around 97 with default options and grow with the parameters. Use
VARCHAR(255)so a future algorithm does not get truncated. - Hashing on the client. If the browser sends
sha256(password), that hash is the password from the server's point of view. Use HTTPS and hash on the server. - Peppers that break bcrypt. Prepending a long secret pushes the real password past 72 bytes. If you want a pepper, apply it as
hash_hmac('sha256', $password, $pepper)and base64-encode the output before bcrypt, and plan for pepper rotation. - Forgetting the cost in tests. Without
BCRYPT_ROUNDS=4, a test suite that creates thousands of users spends minutes hashing. - Leaving MD5 values at rest. A lazy "upgrade on login" migration keeps fast hashes forever for inactive users. Wrap them all immediately, as shown above.
When to Use What (and When Not To)
- New Laravel app: keep the default bcrypt at cost 12 or move to Argon2id if PHP has it and you have the memory. Either way, leave
rehash_on_loginon. - High-traffic login or tight memory: bcrypt is predictable and cheap on RAM. Raise the cost over time instead of switching algorithms.
- Inheriting MD5/SHA-1 hashes: wrap them in bcrypt now, unwrap on login, and force resets for whatever is left after a few months.
- API keys and long random tokens: do not use a password hash. They already have high entropy, so a single SHA-256 is fine, which is exactly how Sanctum stores personal access tokens. Slow hashes are for low-entropy secrets humans choose.
The short version: never store anything fast, let the platform pick salts and encodings, tune the cost on real hardware, and treat every successful login as a free migration step.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
PHP: implement the PasswordUpgrader class. hash(string $password): string returns a bcrypt hash (PASSWORD_BCRYPT) using the cost given to the constructor. verify(string $password, string $storedHash): array returns ['ok' => bool, 'rehash' => ?string]. If $storedHash is exactly 32 lowercase hex characters it is a legacy unsalted MD5: compare it with md5($password) using hash_equals(); on a match return ok true and a fresh bcrypt hash in 'rehash'. Otherwise use password_verify(); on failure return ['ok' => false, 'rehash' => null]; on success return ok true and a fresh hash only when password_needs_rehash() reports the stored hash does not match the configured cost, else null. Python: implement hash_password(password, iterations, salt=None) returning 'pbkdf2_sha256$<iterations>$<salt hex>$<hash hex>' (PBKDF2-HMAC-SHA256, random 16-byte salt when salt is None) and verify_and_upgrade(password, stored, iterations) returning (ok, new_hash_or_None) with the same legacy MD5 rule, constant-time comparisons via hmac.compare_digest, (False, None) for unknown formats, and a new hash only when the stored iteration count is lower than iterations.
Starter code
final class PasswordUpgrader
{
public function __construct(private int $cost = 12) {}
public function hash(string $password): string
{
// TODO: bcrypt the password with $this->cost
throw new LogicException('TODO');
}
/** @return array{ok: bool, rehash: ?string} */
public function verify(string $password, string $storedHash): array
{
// TODO: legacy branch - a 32-char lowercase hex hash is unsalted MD5:
// compare with hash_equals() and rehash on success
// TODO: otherwise password_verify(); on success, rehash only when
// password_needs_rehash() says the stored hash is stale
throw new LogicException('TODO');
}
}
import hashlib
import hmac
import os
import re
def hash_password(password: str, iterations: int, salt: bytes | None = None) -> str:
# TODO: random 16-byte salt when none is given, PBKDF2-HMAC-SHA256,
# return "pbkdf2_sha256$<iterations>$<salt hex>$<hash hex>"
raise NotImplementedError
def verify_and_upgrade(password: str, stored: str, iterations: int) -> tuple[bool, str | None]:
# TODO: 32-char lowercase hex -> legacy MD5, compare with hmac.compare_digest
# TODO: parse the pbkdf2_sha256 format, recompute, compare in constant time
# TODO: on success return a new hash only if stored iterations < iterations
raise NotImplementedError
Your solution
Hints
All hints revealedCheck the legacy MD5 format first with a regex like ^[a-f0-9]{32}$, because password_verify() simply returns false for it
Never compare secrets with == : use hash_equals() in PHP and hmac.compare_digest() in Python
Only produce a new hash after a successful verification; a failed login must return no rehash
final class PasswordUpgrader
{
public function __construct(private int $cost = 12) {}
public function hash(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => $this->cost]);
}
/** @return array{ok: bool, rehash: ?string} */
public function verify(string $password, string $storedHash): array
{
if (preg_match('/^[a-f0-9]{32}$/', $storedHash) === 1) {
$ok = hash_equals($storedHash, md5($password));
return ['ok' => $ok, 'rehash' => $ok ? $this->hash($password) : null];
}
if (! password_verify($password, $storedHash)) {
return ['ok' => false, 'rehash' => null];
}
$stale = password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => $this->cost]);
return ['ok' => true, 'rehash' => $stale ? $this->hash($password) : null];
}
}
import hashlib
import hmac
import os
import re
def hash_password(password: str, iterations: int, salt: bytes | None = None) -> str:
salt = os.urandom(16) if salt is None else salt
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
return f"pbkdf2_sha256${iterations}${salt.hex()}${dk.hex()}"
def verify_and_upgrade(password: str, stored: str, iterations: int) -> tuple[bool, str | None]:
if re.fullmatch(r"[0-9a-f]{32}", stored):
ok = hmac.compare_digest(stored, hashlib.md5(password.encode()).hexdigest())
return ok, (hash_password(password, iterations) if ok else None)
parts = stored.split("$")
if len(parts) != 4 or parts[0] != "pbkdf2_sha256":
return False, None
_, iters, salt_hex, hash_hex = parts
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), int(iters))
if not hmac.compare_digest(dk.hex(), hash_hex):
return False, None
return True, (hash_password(password, iterations) if int(iters) < iterations else None)

