Verifying Yii password hashes in Laravel (no forced resets)

A custom Laravel hasher that accepts Yii2 bcrypt, Yii1 CPasswordHelper, and the md5/sha1 hashes older Yii1 apps used, then lets Laravel's rehash-on-login upgrade every user silently the next time they sign in.

"Will everyone have to reset their password?" is the first question I get on a Yii to Laravel migration call, and the answer is no. Laravel can verify the hashes Yii wrote, and since Laravel 11 it will quietly upgrade each one the next time the user logs in. This post is the code that makes that true, for every hash format I've met in a Yii codebase.

First, find out what you have

Run this against the users table before writing any code:

SELECT LEFT(password, 4) AS prefix, LENGTH(password) AS len, COUNT(*)
FROM user GROUP BY prefix, len ORDER BY 3 DESC;

You'll see one or more of these:

Prefix / length Source Verifies with
$2y$ · 60 Yii2 Security::generatePasswordHash() (cost 13 by default) password_verify()
$2a$ · 60 Yii1 CPasswordHelper::hashPassword() (1.1.14+) password_verify(), but not Laravel's stock bcrypt driver (see below)
32 hex md5($password) — the Yii1 blog tutorial and countless apps that copied it md5() compare
40 hex sha1($password) or sha1($salt . $password) with a salt column sha1() compare, with the salt
128 hex hash('sha512', …) variants same idea, different function

Mixed tables are normal. Someone upgraded the hashing in 2016 and only users who logged in since got the new format.

Why not just use Laravel's bcrypt driver?

If every hash is $2y$, you can. Hash::check() calls password_verify(), cost differences don't matter for verification, and Laravel's needsRehash() will flag Yii's cost-13 hashes for an upgrade to your configured cost. Skip to rehash on login.

Two things break the stock driver:

  1. $2a$ hashes. Laravel's BcryptHasher::check() first asks password_get_info() whether the hash is bcrypt. PHP only labels $2y$ as bcrypt, so a $2a$ hash from Yii1's CPasswordHelper makes the driver throw "This password does not use the Bcrypt algorithm" (when hashing.bcrypt.verify is on, which it is by default). password_verify() itself handles $2a$ fine; the driver just refuses to call it.
  2. md5 / sha1. Obviously.

So: a hasher that extends the bcrypt driver, recognizes the legacy formats, and reports them as needing a rehash.

The legacy-aware hasher

<?php

namespace App\Auth;

use Illuminate\Hashing\BcryptHasher;

/**
 * Verifies password hashes written by Yii1/Yii2 and reports every legacy
 * format as needing a rehash, so Laravel upgrades users to bcrypt on login.
 */
class YiiLegacyHasher extends BcryptHasher
{
    public function check(#[\SensitiveParameter] $value, $hashedValue, array $options = []): bool
    {
        if ($hashedValue === null || $hashedValue === '') {
            return false;
        }

        // Yii2 (password_hash) and modern Laravel: let the parent verify.
        if (str_starts_with($hashedValue, '$2y$')) {
            return parent::check($value, $hashedValue, $options);
        }

        // Yii1 CPasswordHelper: bcrypt with the $2a$ prefix. password_verify() accepts it;
        // Laravel's driver would reject it before getting there.
        if (str_starts_with($hashedValue, '$2a$')) {
            return password_verify($value, $hashedValue);
        }

        // Unsalted md5 / sha1 (Yii1 tutorial era). Constant-time compare.
        if (preg_match('/^[a-f0-9]{32}$/i', $hashedValue)) {
            return hash_equals(strtolower($hashedValue), md5($value));
        }
        if (preg_match('/^[a-f0-9]{40}$/i', $hashedValue)) {
            return hash_equals(strtolower($hashedValue), sha1($value));
        }

        return false;
    }

    public function needsRehash($hashedValue, array $options = []): bool
    {
        // Anything that isn't a $2y$ bcrypt at our cost gets upgraded on next login.
        if (!str_starts_with((string) $hashedValue, '$2y$')) {
            return true;
        }

        return parent::needsRehash($hashedValue, $options);
    }

    public function info($hashedValue): array
    {
        $info = parent::info($hashedValue);

        // Hash::isHashed() asks the driver for info()['algo']; report the legacy
        // formats as hashed so the 'hashed' model cast never re-hashes them.
        if ($info['algo'] === null && (
            str_starts_with((string) $hashedValue, '$2a$')
            || preg_match('/^[a-f0-9]{32}$|^[a-f0-9]{40}$/i', (string) $hashedValue)
        )) {
            return ['algo' => 'legacy', 'algoName' => 'legacy', 'options' => []];
        }

        return $info;
    }
}

The info() override matters if your User model uses Laravel's 'password' => 'hashed' cast: Hash::isHashed() is answered by the driver's info(), and without it, assigning a legacy hash to the model (in a seeder, a sync script, anything) would get it hashed again.

Register it and make it the default driver:

// app/Providers/AppServiceProvider.php
use App\Auth\YiiLegacyHasher;
use Illuminate\Support\Facades\Hash;

public function boot(): void
{
    Hash::extend('yii', fn () => new YiiLegacyHasher(config('hashing.bcrypt', [])));
}
// config/hashing.php
'driver' => 'yii',
'bcrypt' => ['rounds' => env('BCRYPT_ROUNDS', 12), 'verify' => true],
'rehash_on_login' => true,

make() is inherited untouched, so every new hash Laravel writes is plain bcrypt at your cost. The legacy formats are read-only paths that exist to get users through the door once.

Salted sha1: when the hasher can't see the salt

Hash::check($plain, $hash) only receives the hash. If Yii1 stored sha1($salt . $password) with the salt in its own column, the check needs the user row. The clean place for that is the user provider:

<?php

namespace App\Auth;

use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;

class YiiUserProvider extends EloquentUserProvider
{
    public function validateCredentials(Authenticatable $user, #[\SensitiveParameter] array $credentials): bool
    {
        $plain = $credentials['password'];
        $hash  = $user->getAuthPassword();

        // Salted legacy format first; everything else goes through the hasher.
        if ($user->salt && preg_match('/^[a-f0-9]{40}$/i', $hash)) {
            return hash_equals(strtolower($hash), sha1($user->salt . $plain));
        }

        return $this->hasher->check($plain, $hash);
    }

    public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false): void
    {
        parent::rehashPasswordIfRequired($user, $credentials, $force);

        // Once the hash is bcrypt the salt is dead weight; clear it so the branch above stops matching.
        if ($user->salt && str_starts_with($user->getAuthPassword(), '$2y$')) {
            $user->forceFill(['salt' => null])->save();
        }
    }
}
// AppServiceProvider::boot()
Auth::provider('yii', fn ($app, array $config) => new YiiUserProvider($app['hash'], $config['model']));
// config/auth.php
'providers' => [
    'users' => ['driver' => 'yii', 'model' => App\Models\User::class],
],

Adjust the concatenation order ($salt . $password vs $password . $salt) to match the Yii code; check UserIdentity::authenticate() or the model's validatePassword() to see which it was.

Let Laravel rehash on login

With hashing.rehash_on_login enabled (the default since Laravel 11), Auth::attempt() does this after a successful check:

  1. Asks the hasher needsRehash($storedHash).
  2. If true, calls Hash::make($plainPassword) and saves it on the user.

The hasher above answers true for every legacy format and for $2y$ hashes at a different cost, so the first successful login through Laravel rewrites the row as a fresh bcrypt at your configured rounds. No email, no reset flow, no support tickets. Users who never log in again keep their old hash, which is harmless; you can force-expire those accounts after the migration if policy requires it.

Two edge cases:

  • Users who log in through the Yii app during the migration keep their old hash until they log in through Laravel. That's fine as long as login is one of the first modules you move (it should be; see running Yii and Laravel side by side).
  • Password changes and resets must happen in Laravel only, so the row is always written as $2y$ from now on. Point Yii's "change password" links at the Laravel route.

Schema changes you'll need

Yii1 tables that stored md5 often declare password VARCHAR(32) or CHAR(32). Bcrypt needs 60 characters. Widen it before the first login through Laravel:

Schema::table('user', function (Blueprint $table) {
    $table->string('password', 255)->change();
});

Other columns worth reconciling while you're here:

  • Yii2's auth_key (32 chars) backs "remember me". Laravel wants remember_token (holds a 60-character token). Either add remember_token and let Laravel own it, or point getRememberTokenName() at auth_key and widen the column. Adding the column is simpler.
  • Yii2's password_reset_token column → Laravel uses its own password_reset_tokens table. Leave the column alone until Yii is gone, then drop it.
  • Yii's status integer (10 = active) → keep it, and add a canLogin() check to your login request or a middleware.

Testing it

it('accepts every legacy Yii hash and upgrades it on login', function () {
    $hasher = new YiiLegacyHasher(['rounds' => 4]);

    foreach ([
        password_hash('secret', PASSWORD_BCRYPT, ['cost' => 5]),                  // Yii2 (its cost differs from ours)
        '$2a$'.substr(password_hash('secret', PASSWORD_BCRYPT, ['cost' => 5]), 4),  // Yii1 CPasswordHelper
        md5('secret'),                                                            // Yii1 md5
        sha1('secret'),                                                           // Yii1 sha1
    ] as $legacy) {
        expect($hasher->check('secret', $legacy))->toBeTrue()
            ->and($hasher->check('wrong', $legacy))->toBeFalse()
            ->and($hasher->needsRehash($legacy))->toBeTrue();
    }

    expect($hasher->needsRehash($hasher->make('secret')))->toBeFalse();
});

Then one HTTP test: create a user with an md5 hash, post('/login'), assert the session is authenticated and the stored hash now starts with $2y$.

Removing the legacy code later

Six months after Yii is retired, check how many rows still aren't $2y$:

SELECT COUNT(*) FROM user WHERE password NOT LIKE '$2y$%';

Expire those accounts or send them a reset link, switch hashing.driver back to bcrypt, and delete the hasher. The migration leaves nothing behind.

When to get help

This is a contained piece of work, and if your hashes are all $2y$ it's an afternoon. It gets interesting when the table mixes three formats and nobody can find the code that wrote the oldest one, or when the same users also exist in a second system (a WordPress front end, a mobile app backend) that has to keep verifying them. That's the kind of thing I sort out in the first week of a migration, and it's covered in the assessment.

Kelly Brintle

Principal Architect at CraftWeb. I move legacy PHP applications to Laravel and Vue and build platforms for businesses that run on them. More about me