- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a PHP project in 2026. The user model calls password_hash($pwd, PASSWORD_DEFAULT). That constant still resolves to bcrypt. PHP 8.4 ships with Argon2id available. The Composer-installed Laravel skeleton you cloned last week still configures bcrypt as the hashing driver.
That's the bug. Not a critical CVE, not a "rotate your keys" kind of bug. A slow, boring one. Your user passwords are protected by a 1999 algorithm against attackers running 2026 hardware.
The good news: the fix is two lines, and PHP rehashes users on next login for free.
bcrypt's 2026 problem
bcrypt was designed in 1999 by Niels Provos and David Mazières. It was great. The work factor is tunable, it's salt-aware, and for two decades it was the right default for password hashing.
The problem is hardware. bcrypt is CPU-bound and uses a small, fixed 4KB of memory per hash. That fits comfortably inside the local cache of any modern GPU core. A consumer-grade RTX 4090 cracks bcrypt at work factor 12 at roughly 200,000 hashes per second. ASIC rigs do more. Cloud-rented hashcat clusters do more still.
The work factor is supposed to save you. Bump cost from 10 to 12 and you 4x the attack time. The catch is you also 4x the login time on your own server. At cost 14 you're looking at ~1 second per legitimate login on a typical web tier. Past that, real users notice the latency on every sign-in.
So you sit at cost 10-12, which is what most production PHP code does. And at cost 12, a determined attacker with a leaked database table cracks the weak half of your users overnight.
That's the gap Argon2id was designed to close.
Argon2id: memory-hard, GPU-resistant, NIST-recommended
Argon2 won the Password Hashing Competition in 2015. It has three variants: Argon2d, Argon2i, and Argon2id. The id variant is the one you want. It mixes data-dependent passes (resistant to time-memory tradeoffs) with data-independent passes (resistant to side-channel attacks).
The key design choice: Argon2id is memory-hard. You can crank the memory_cost parameter so each hash needs 64MB or 256MB of RAM. That doesn't slow your server down much. Your web tier has gigabytes free. It slows GPUs down a lot. A GPU core that fits 4KB of bcrypt state in cache cannot fit 64MB of Argon2 state. Suddenly the attacker's parallelism collapses from 10,000 simultaneous cracks per GPU to maybe 50.
NIST SP 800-63B-3 and the OWASP Password Storage Cheat Sheet both list Argon2id as the recommended algorithm for new applications. bcrypt is "acceptable" with caveats. That's the polite way of saying: don't pick it for greenfield.
PHP added the PASSWORD_ARGON2ID constant in 7.2. You've had this for eight years. It just hasn't been the default.
The two-line migration
Here's the entire change to your hashing code:
// before
$hash = password_hash($password, PASSWORD_DEFAULT);
// after
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4,
'threads' => 1,
]);
password_verify() doesn't change. It reads the algorithm and parameters from the hash string itself. The stored hash looks like:
$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQ...$encoded_hash
Everything verify needs is in there. So your verification code keeps working unchanged:
final class UserAuthenticator
{
public function login(string $email, string $password): ?User
{
$user = $this->users->findByEmail($email);
if ($user === null) {
return null;
}
if (!password_verify($password, $user->passwordHash)) {
return null;
}
return $user;
}
}
That's the whole runtime change. New signups get Argon2id immediately. Existing users still log in with their bcrypt hashes because password_verify handles both.
Tuning the parameters
The three knobs:
-
memory_cost(kilobytes): how much RAM per hash. 65536 = 64MB. This is the GPU-killer. -
time_cost: number of iterations. More iterations = slower hash, linearly. -
threads: parallelism per hash. Usually 1 on web tiers (PHP-FPM workers are already separate processes).
The OWASP cheat sheet recommends m=19456 (~19MB), t=2, p=1 as a minimum. That's the floor. On modern hardware you can do better. The configuration above (m=65536, t=4) takes about 80ms on a typical x86_64 server core in 2026. Fast enough that users don't notice, slow enough that GPU attacks crawl.
Benchmark on your own hardware before shipping. Don't copy a number from a blog post (including this one). Run this:
$start = microtime(true);
password_hash('a-test-password-of-realistic-length', PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 1,
]);
echo (microtime(true) - $start) * 1000, " ms\n";
Aim for 50–250ms per hash. Below 50ms you're not making attackers work hard enough. Above 250ms users feel the login lag and you'll get tickets.
The gotcha: don't crank memory_cost past your PHP-FPM worker's memory_limit. If you set memory_cost to 524288 (512MB) on a worker capped at 256MB, every login crashes the worker with PHP Fatal error: Allowed memory size exhausted. Argon2id allocates the buffer up front. Test under load.
Migrating existing users: rehash on next login
The cleanest migration path uses password_needs_rehash(). PHP returns true when a stored hash's algorithm or parameters don't match what you'd produce today. You compute it on every successful login, and if true, you rehash with the current settings and write it back.
final class UserAuthenticator
{
private const HASH_ALGO = PASSWORD_ARGON2ID;
private const HASH_OPTS = [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 1,
];
public function login(string $email, string $password): ?User
{
$user = $this->users->findByEmail($email);
if ($user === null || !password_verify($password, $user->passwordHash)) {
return null;
}
// user's hash is bcrypt or argon2id with weaker params,
// so re-hash transparently while we have the plaintext
if (password_needs_rehash($user->passwordHash, self::HASH_ALGO, self::HASH_OPTS)) {
$user->passwordHash = password_hash($password, self::HASH_ALGO, self::HASH_OPTS);
$this->users->save($user);
}
return $user;
}
}
That's the whole migration. There's no batch job. No flag day. No forced password reset email. Active users upgrade themselves over their next login. Inactive users stay on bcrypt, which is fine, because if they never log in, the bcrypt hash never matters operationally.
The pattern is also forward-compatible. Bump memory_cost next year and password_needs_rehash flags every user whose stored params are below the new floor. They re-upgrade silently.
One thing the docs are quiet about: password_needs_rehash only fires inside a successful login flow because you need the plaintext password to compute a new hash. You can't batch-migrate hashes offline. That's by design. The plaintext exists in your code's memory for exactly one request, then it's gone.
Laravel still defaults to bcrypt
Laravel 11 and 12 still ship with bcrypt as the default hasher. Open config/hashing.php:
return [
'driver' => env('HASH_DRIVER', 'bcrypt'),
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => true,
],
'argon' => [
'memory' => 65536,
'threads' => 1,
'time' => 4,
'verify' => true,
],
];
Switch the driver to argon2id:
'driver' => env('HASH_DRIVER', 'argon2id'),
Or set HASH_DRIVER=argon2id in your .env. Laravel's Hash facade routes through this config, so Hash::make($password) now produces Argon2id hashes and Hash::check($password, $stored) still handles both.
The Hash::needsRehash() helper is the Laravel-friendly version of password_needs_rehash. Hook it into your login controller (or the Authenticated event listener):
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Support\Facades\Hash;
final class RehashPasswordOnLogin
{
public function handle(Authenticated $event): void
{
$user = $event->user;
$plaintext = request()->input('password');
if ($plaintext && Hash::needsRehash($user->password)) {
$user->forceFill([
'password' => Hash::make($plaintext),
])->save();
}
}
}
Register it in EventServiceProvider. Same migration pattern as plain PHP: silent, per-user, on next login.
A nit on the Laravel config: config/hashing.php lets you set argon parameters but reads them only when the driver is set to argon or argon2id. Setting argon.memory while leaving driver on bcrypt does nothing. Make sure both change together.
When bcrypt is still OK
Three cases:
- Legacy systems with no application-layer access. Some old PHP apps store hashes in a database read by a different language's auth layer. If you can't deploy the migration code, leaving bcrypt at cost 12 or 13 is still meaningfully better than MD5, SHA-1, or unsalted SHA-256.
-
Resource-constrained environments. Embedded PHP (rare in 2026) or shared hosting with 32MB
memory_limitper worker can't run Argon2id at any useful parameter set. bcrypt at cost 12 stays viable there. - You're moving away in 90 days anyway. If the codebase is being decommissioned or rewritten, don't churn the security stack for a temporary migration.
For everything else (new code, active codebases, anything you're going to maintain past Q3 2026), Argon2id is the right answer. PASSWORD_DEFAULT may eventually catch up, but waiting for that is waiting for the wrong reason. The constant is there. Use it.
What's your current hashing config in production, and when did you last check it?
If this was useful
Password hashing is the kind of decision that gets baked into the framework defaults and then never revisited. The reason this post even needs writing is that the choice between bcrypt and Argon2id usually lives in two lines of code buried in a UserService or an authentication middleware. Exactly the kind of detail that gets harder to change the longer your domain code depends on it. Decoupled PHP is about the architectural layer your codebase reaches for after it outgrows the framework defaults, so swapping a hasher (or an ORM, or a queue) becomes a config change instead of a rewrite.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (1)
One thing I'd push back on: "Inactive users stay on bcrypt, which is fine, because if they never log in, the bcrypt hash never matters operationally." That's true if your only threat model is unauthorized login to your service. But if the database leaks, those bcrypt hashes are the easiest to crack, and inactive users are the least likely to have rotated their passwords anywhere else. So the attacker cracks the weaker bcrypt hashes first, then stuffs those credentials against banking, email, and cloud accounts where the same password is still live. The hash absolutely matters — just not on your system.
The wrapping trick handles this: compute
argon2id(existing_bcrypt_hash)offline for every row, then adjust your verify path to double-hash on login until the user gets a clean argon2id-only hash. No plaintext needed, no waiting for login, and the bcrypt hashes in your database are no longer the weakest link if someone walks out with a dump.