Set HASH_DRIVER=argon2id and use Laravel's Hash facade: Hash::make() writes a salted Argon2id hash that records its parameters, Hash::check() verifies it in constant time, and Hash::needsRehash() after a correct password tells you to store a new hash. Never hash passwords with hash(), md5() or sha1(), and never copy BCRYPT_ROUNDS=4 from phpunit.xml.
- Find every password hash written or compared by hand: hash(), md5(), sha1(), crypt(), == or === on stored hashes, and every Hash::make() call that passes its own rounds or memory options.
- Set HASH_DRIVER=argon2id in the environment (PHP must be built with Argon2 support, as the official Docker images are), or run php artisan config:publish hashing to set the driver and ARGON_MEMORY, ARGON_TIME and ARGON_THREADS explicitly. Laravel's Argon2 defaults are 65536 KiB, 4 passes and 1 thread.
- Hash with Hash::make($password) and verify with Hash::check($password, $stored); with Auth::attempt(), Laravel rehashes on login when the work factor changes (hashing.rehash_on_login, on by default).
- For legacy rows, recognise the old format, compare with hash_equals() in constant time, and after a correct password store Hash::make($password) in place of the old value, replacing only the hash you verified.
- Cap the password length (here 256 characters, counted with mb_strlen()) instead of truncating it, and accept at least 64 characters, as NIST SP 800-63B-4 asks.
- Add regression tests that read the stored hash the way an attacker with a copy of the table would: Argon2id in PHC format at or above an OWASP minimum, a different salt per user, and legacy hashes upgraded after login.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public function hash(string $password): string
{
return hash('sha256', $password);
}With IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).
The fix: Argon2id through the Hash facade
// Argon2id with Laravel's defaults: 64 MiB (65536 KiB) of memory, 4 passes, 1 thread, above the
// OWASP minimum. In an app, set HASH_DRIVER=argon2id and call Hash::make(); ARGON_MEMORY,
// ARGON_TIME and ARGON_THREADS raise the cost.
public function hash(string $password): string
{
return Hash::driver('argon2id')->make($password);
}With IMPL=fixed, 12 of 12 tests pass.
The fix: verify and upgrade
public function verify(string $password, string $stored): Verification
{
if (preg_match('/\A[0-9a-f]{64}\z/', $stored)) {
// A row from the old system: unsalted SHA-256. Compare in constant time, and ask for
// a rehash so the next successful login replaces it.
$valid = hash_equals($stored, hash('sha256', $password));
return new Verification(valid: $valid, needsRehash: $valid);
}
$hasher = Hash::driver('argon2id');
$valid = $hasher->check($password, $stored);
// needsRehash() is true for a hash made with other parameters than today's.
return new Verification(valid: $valid, needsRehash: $valid && $hasher->needsRehash($stored));
}With IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
// BCRYPT_ROUNDS=4, copied from phpunit.xml to make logins faster.
private const OPTIONS = ['rounds' => 4];
public function hash(string $password): string
{
return Hash::driver('bcrypt')->make($password, self::OPTIONS);
}With IMPL=wrong, 4 of 12 tests fail (3 exploit tests).
Login: rehash after a correct password
public function login(Request $request): JsonResponse
{
$credentials = self::credentials($request);
if ($credentials === null) {
return self::badRequest();
}
[$username, $password] = $credentials;
$account = DB::table('accounts')->where('username', $username)->first();
$result = $this->passwords->verify($password, $account->password_hash ?? $this->dummyHash());
if ($account === null || ! $result->valid) {
return response()->json(['error' => 'invalid username or password'], 401);
}
if ($result->needsRehash) {
// Only now is the plaintext known to be right. Replace exactly the hash that was
// verified: if a password change landed in the meantime, no row matches.
DB::table('accounts')->where('id', $account->id)->where('password_hash', $account->password_hash)
->update(['password_hash' => $this->passwords->hash($password)]);
}
return response()->json(['username' => $username]);
}The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: what a stolen copy of the accounts table gives an attacker, and how hashes upgrade.
public function test_exploit_a_new_hash_is_argon2id_at_or_above_the_owasp_minimum(): void
{
$this->assertSame(201, $this->register('ivan', 'ivan-has-a-long-passphrase')->status());
$this->assertMeetsPolicy($this->storedHash('ivan'));
}
public function test_exploit_the_same_password_gives_each_user_a_different_hash(): void
{
$this->register('judy', self::LEGACY_PASSWORD);
$this->register('mallory', self::LEGACY_PASSWORD);
$this->assertNotSame($this->storedHash('judy'), $this->storedHash('mallory'));
}
public function test_exploit_a_legacy_sha256_hash_is_replaced_after_a_successful_login(): void
{
$legacy = $this->storedHash('alice');
$this->assertSame(200, $this->login('alice', self::LEGACY_PASSWORD)->status());
$this->assertNotSame($legacy, $this->storedHash('alice'));
$this->assertMeetsPolicy($this->storedHash('alice'));
$this->assertSame(200, $this->login('alice', self::LEGACY_PASSWORD)->status(), 'the new hash verifies');
}
public function test_exploit_a_wrong_password_is_rejected_and_leaves_the_legacy_hash_alone(): void
{
$legacy = $this->storedHash('alice');
$this->assertSame(401, $this->login('alice', 'correct horse battery stapler')->status());
$this->assertSame($legacy, $this->storedHash('alice'));
}
public function test_exploit_a_hash_with_weaker_parameters_is_rehashed_after_login(): void
{
// 4 MiB and 3 passes: below every OWASP minimum configuration.
$weak = password_hash(self::LEGACY_PASSWORD, PASSWORD_ARGON2ID, ['memory_cost' => 4096, 'time_cost' => 3, 'threads' => 1]);
DB::table('accounts')->where('username', 'bob')->update(['password_hash' => $weak]);
$this->assertSame(200, $this->login('bob', self::LEGACY_PASSWORD)->status());
$this->assertMeetsPolicy($this->storedHash('bob'));
}
public function test_exploit_the_legacy_hash_comparison_is_constant_time(): void
{
HashEqualsSpy::$calls = 0;
$this->assertSame(200, $this->login('alice', self::LEGACY_PASSWORD)->status());
$this->assertGreaterThan(0, HashEqualsSpy::$calls, 'the SHA-256 comparison did not use hash_equals()');
}- The Laravel skeleton's phpunit.xml sets BCRYPT_ROUNDS=4 so test suites run fast. Copied into production it makes every guess cheap; that is this card's wrong implementation. Laravel's own default is 12 rounds (.env.example sets BCRYPT_ROUNDS=12).
- Bcrypt uses only the first 72 bytes of a password: on PHP 8.5.11, password_verify() accepted a different 87-byte password that shared the first 72. Laravel's BCRYPT_LIMIT setting makes Hash::make() throw for longer values; Argon2id has no such limit.
- Hash::check() verifies that a stored hash uses the configured algorithm and throws RuntimeException if not (HASH_VERIFY, on by default). When moving from bcrypt to Argon2id, check old hashes with the bcrypt driver (Hash::driver('bcrypt')) or turn HASH_VERIFY off for the migration, then rehash after login.
- Comparing hex digests with == or === leaks timing and, with ==, PHP's loose comparison treats two numeric-looking strings such as '0e123' and '0e456' as equal. Use hash_equals() for any secret comparison.
- Unsalted hashes give equal passwords equal hashes: in this card's seed, Alice and Bob share one, so cracking it once reveals both.
- Do not pre-hash with SHA-256 to get around bcrypt's 72-byte limit unless you encode the digest (raw binary may contain NUL bytes, which PHP's bcrypt refuses); Argon2id avoids the question.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs password-hashing/php-laravel does this for you in the pinned composer image, so it needs Docker).
- Register a test account and read its row: the hash must start with $argon2id$v=19$m=65536,t=4,p=1$ (or your stronger settings), two accounts with the same password must have different hashes, and a legacy row must change after a successful login.
- grep for hash(', md5(, sha1(, crypt( and === near password code, and for BCRYPT_ROUNDS in every .env; Semgrep's weak-crypto rule flags md5() and sha1() but not hash('sha256') or a low bcrypt cost.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | php.lang.security.weak-crypto.weak-crypto | No findings on this card: it flags calls such as md5() and sha1(), and a probe with md5($password) was flagged, but hash('sha256', $password) in VulnerablePasswords.php is not. Bcrypt with a cost of 4 in WrongPasswords.php is not flagged either.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/<id> app |
| Semgrep | php.lang.security.md5-used-as-password.md5-used-as-password | No findings: the card does not use md5().Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3) |
| Semgrep | r/php (every PHP rule in the Semgrep registry, 47 rules) | No findings on any implementation.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes config bootstrap |
| Psalm | taint analysis (all taint kinds)Psalm taint analysis with the Laravel plugin | No issues: Psalm has no check for weak password hashing or for == comparison of secrets.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/ |
CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript.
| Component | Version |
|---|---|
| Language | PHP |
| Runtime | PHP 8.5.11 (CLI) |
| Framework | Laravel 13.34.0 (Hash facade) |
| laravel/framework | 13.34.0 |
| phpunit/phpunit | 13.4.0 |
| pdo_sqlite (bundled with PHP) | PHP 8.5.11; SQLite 3.53.4 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the password-hashing/php-laravelfolder.
composer install --no-interaction --no-progress# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xmlWithout PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'composer install --no-interaction --no-progress'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=fixed \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=vulnerable \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=wrong \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs password-hashing/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
- Toolchain
- php 8.5.11 (CLI)
- laravel/framework 13.34.0
- phpunit/phpunit 13.4.0
- composer 2.10.3
- semgrep 1.180.0
- psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 5 of 12 tests fail (5 exploit tests); wrong: 4 of 12 tests fail (3 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 5/12 fail (5 exploit tests); wrong 4/12 fail (3 exploit tests), each test run in a container with --network=none after composer install
- Dependency freshness
- Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Password Storage Cheat Sheet
- Laravel 13.x: Hashing, configuration (HASH_DRIVER)
- Laravel 13.x: Adjusting the Argon2 work factor
- Laravel 13.x: Determining if a password needs to be rehashed
- Laravel 13.x: Hash algorithm verification (HASH_VERIFY)
- Laravel 13.x: Automatic password rehashing
- PHP manual: password_hash() (PASSWORD_ARGON2ID and bcrypt's 72-byte limit)
- PHP manual: hash_equals()
- RFC 9106: Argon2 (parameter choice)
- NIST SP 800-63B-4: Password verifiers
Attack patterns: