Migrate Password Hashing

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

On our User entity, this $password field - which is stored in the database - does not contain a plain-text version of the user's password:

... lines 1 - 19
class User implements UserInterface
{
... lines 22 - 47
/**
* @ORM\Column(type="string", length=255)
*/
private $password;
... lines 52 - 283
}

Next to allowing SQL injection attacks, storing plain-text passwords is just about the worst thing you can do in a web app.

Hashing Algorithms Over Time

Anyways, what's actually stored on this field is a "hash" or kind of "fingerprint" of the plaintext password and there are multiple hashing algorithms available. The one you're using is configured in config/packages/security.yaml:

security:
encoders:
App\Entity\User:
algorithm: bcrypt
... lines 5 - 61

The encoders section says that whenever we encode, or really, "hash" a password - like when someone registers or when they log in - the bcrypt algorithm will be used. That's great. But... over time, as processing power of computers get better and better, it becomes more and more possible that if your database of passwords somehow got exposed, someone could use a computer to crack them. It probably won't happen, but it's a security best-practice to change your algorithm over time to one that requires more processing power or memory.

Changing Algorithms

Comment-out the bcrypt algorithm and replace it with sodium:

security:
encoders:
App\Entity\User:
#algorithm: bcrypt
algorithm: sodium
... lines 6 - 62

This stuff can be confusing. Sodium is a hashing library that uses the Argon2 algorithm, which is currently considered the best algorithm.

So... great! We just changed from bcrypt to Argon2 and increased the security of our application. We deserve a donut!

Wait a second... put that donut down. You - usually - can't simply change from one algorithm to another. Why? The problem is that all your existing users already have their passwords hashed with bcrypt. If those users tried to log in, suddenly Symfony would use sodium to hash the submitted password and it would not match the hash in the database.

Now, the full truth is that, in this case - going from bcrypt to sodium - nothing would break: Sodium is smart enough to detect that the existing passwords are hashed with bcrypt and use it instead. But in general, you can't change from one algorithm to another without breaking stuff. And even in this case, shouldn't we also re-hash the passwords of all our existing users with the newer algorithm?

The migrate_from Encoder Option

Symfony 4.4 comes with a wonderful new feature to help with this - submitted by the amazing Nicolas Grekas, who is also responsible - along with Jérémy Derussé for the secrets management system.

Here's how it works: add a new encoder, it can be called anything, how about legacy_bcrypt. Make sure it has the exact configuration of your original encoder:

security:
encoders:
legacy_bcrypt:
algorithm: bcrypt
... lines 5 - 68

Next, under the new encoder - the one that will be used for my User class - add a new option: migrate_from. Below that, add a list of all encoders that existing users might be using - for us, just legacy_bcrypt:

security:
encoders:
legacy_bcrypt:
algorithm: bcrypt
App\Entity\User:
algorithm: sodium
migrate_from:
# allow existing bcrypt accounts to log in
# and migrate to sodium
- legacy_bcrypt
... lines 12 - 68

That's it! This says:

Hey! When somebody logs in, try to use the sodium algorithm. If that doesn't work, try the legacy_bcrypt algorithm. If that doesn't work, panic! I mean, if that doesn't work, the password is invalid.

Thanks to this, we can have a database where some passwords are hashed with sodium and others are hashed with bcrypt. Let's try it: log out and try to log back in: admin1@thespacebar.com, password engage. Got it!

Seeing the Hashed Passwords

It's also kinda fun to see how this looks in the database. Find your terminal and run:

php bin/console doctrine:query:sql 'SELECT email, password FROM user'

Interesting: every hashed password starts with the same $2y thing. That's no accident: that's what the bcrypt hashing format looks like.

Let's see what sodium-encoded passwords look like: go back to your browser, log out, and register as a new user: Ryan, spacecadet@example.com, the same password - engage, but that doesn't matter - and register!

Try that query again:

php bin/console doctrine:query:sql 'SELECT email, password FROM user'

Cool! It's pretty obvious the new user's password is hashed with Argon.

Upgrading old Password

We now have a database mixed with passwords hashed with the older algorithm and the newer algorithm. That's fine... but in a perfect world, we would re-hash all the passwords using the newer algorithm.

But... we can't do that. Boo. In order to hash a password, we need the original plain password, which we don't have. So it's not possible to upgrade all existing users to the new algorithm.

Except, hmm, there is one time when we do have the plaintext password: at the moment any old user logs into the site. At that instant, in theory, we could re-hash the password using sodium and save it to the database. That would actually be pretty awesome.

And... that's precisely what migrate_from does automatically:

security:
encoders:
... lines 3 - 5
App\Entity\User:
... line 7
migrate_from:
... lines 9 - 68

Well, almost automatically: we need to do two things in our code to enable it.

Guard PasswordAuthenticatedInterface

First, if you're using Guard authentication for your login form, your authenticator needs a new interface. I'll open up src/Security/LoginFormAuthenticator.php and add implements PasswordAuthenticatedInterface:

... lines 1 - 17
use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface;
... lines 19 - 20
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
... lines 23 - 93
}

Basically, we need to tell the system what the plain-text password is. I'll scroll down and then go to the "Code"->"Generator" menu - or Command+N on a Mac - to generate the required getPassword() method:

... lines 1 - 20
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
... lines 23 - 75
public function getPassword($credentials): ?string
{
... line 78
}
... lines 80 - 93
}

Look up at getCredentials():

... lines 1 - 20
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
... lines 23 - 44
public function getCredentials(Request $request)
{
$credentials = [
'email' => $request->request->get('email'),
'password' => $request->request->get('password'),
'csrf_token' => $request->request->get('_csrf_token'),
];
$request->getSession()->set(
Security::LAST_USERNAME,
$credentials['email']
);
return $credentials;
}
... lines 60 - 93
}

We return an array with the email, password, and csrf_token keys. In getPassword(), we're passed that array as the $credentials argument. To get the password, return $credentials['password']:

... lines 1 - 20
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
... lines 23 - 75
public function getPassword($credentials): ?string
{
return $credentials['password'];
}
... lines 80 - 93
}

UserRepository PasswordUpgraderInterface

The second change we need to make is inside src/Repository/UserRepository.php. Implement a new interface here too called PasswordUpgraderInterface:

... lines 1 - 7
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
... lines 9 - 16
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
... lines 19 - 92
}

This requires one new method. Go to the "Code"->"Generate" menu - or Command+N on a Mac - select "Implement Methods" and choose upgradePassword():

... lines 1 - 8
use Symfony\Component\Security\Core\User\UserInterface;
... lines 10 - 16
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
... lines 19 - 59
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
}
... lines 64 - 92
}

Here's the idea: when we log in, if the user's password is hashed with an old algorithm, the security system will call getPassword() on our authenticator to get the plain-text password and then hash it using the latest algorithm. To save that newly-hashed string to the user table, it will call this upgradePassword() method and pass it to us.

So, our job here is to update the database. I'll add a little PHPDoc above this method: we know the $user variable will be our User object:

... lines 1 - 16
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
... lines 19 - 59
/**
* @param User $user
*/
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
... lines 65 - 66
}
... lines 68 - 96
}

Now add $user->setPassword($newEncodedPassword) and then $this->getEntityManager()->flush($user):

... lines 1 - 16
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
... lines 19 - 59
/**
* @param User $user
*/
public function upgradePassword(UserInterface $user, string $newEncodedPassword): void
{
$user->setPassword($newEncodedPassword);
$this->getEntityManager()->flush($user);
}
... lines 68 - 96
}

That's it! Test drive time! Find your browser and log out. Log back in with admin1@thespacebar.com, password engage. It works. But the real test is what the database looks like! Run that query again:

php bin/console doctrine:query:sql 'SELECT email, password FROM user'

Scroll up and... there it is! admin0 still has the bcrypt format but admin1 - the user we just logged in as - has an argon-hashed password!

So that's it! By adding a few lines of config and two simple methods, our existing users will be upgraded to the latest algorithm safely over time. And we can brag about this cool feature to our friends.

Next, we're just about done with our tour through my favorite new Symfony 5 features. But before we're done, I want to talk about PHP 7.4 preloading and a way to double-check that service wiring across your entire app is working correctly. Because, surprise! We have a hidden bug.

Leave a comment!

  • 2020-04-17 Holger Maerz

    I have waited for this feature for years.

  • 2020-02-04 weaverryan

    Yo Kevin Bond!

    You got it - bcrypt works with argon, which is what auto is using right now anyways.

    > I assume, in the future, if a better algo is available, they will be seamlessly migrated to it.

    That's my impression as well :). Though, I believe (this is from talking with Nicolas) that even if you chose Sodium, if you needed to change to something else in the future, that change should be seamless as well. They key thing is that modern encoders embed the algorithm details into the hash, which means that future hashers can handle them. That's precisely why sodium (which uses argon) is able to handle your bcrypt passwords without any issue.

    A tl;dr on this might be:
    A) Try switching to auto! Can you still log in using your older users! Coolio! Use that - the "migrate_from" is built-in.
    B) If not, use migrate_from so that you can explicitly tell Symfony how to handle your old users.

    Cheers!

  • 2020-02-04 Kevin Bond

    Thanks Ryan,

    My app currently uses bcrypt for all users. I want to migrate them to the latest but also never have to worry about upgrading them to the latest again. This is why auto was so appealing to me.

    I looked into this a bit further and did some testing. Turns out simply switching my algo from "bcrypt" to "auto" works out of the box. Existing users with bcrypt passwords can still login. Once I implemented the appropriate migration interfaces passwords are properly migrated to "argon2id" on login. I assume, in the future, if a better algo is available, they will be seamlessly migrated to it.

  • 2020-02-04 weaverryan

    Hey Gustavo and @Kevin Bond!

    Great question - I should have mentioned that! Algorithm auto is *perfect* to use. I was focusing more on the case of "how do I upgrade my old algorithm (auto is not an old algorithm) to a new one" and the migrate_from is perfect for that.

    To be clear, a few points:
    A) auto should be used for new projects... as it manages everything for you. As a bonus, it comes migrate_from "built-in" - but only for a few, specific encoders/algorithms. You can specify *another* algorithm it should include in its built-in migrate_from via the hash_algorithm option... but basically I find that "auto" is confusing if you're trying to use it to *migrate* from an old encoder. But it's perfect for new projects.

    B) auto and the migrate_from option are incompatible with each other: you can only use one of them, not both together. If you're migrating passwords from an old algorithm/encoder, the migrate_from is much clearer to use.

    Btw, credit to Nicolas Grekas who was answering my original questions about all of this for the tutorial :).

    Cheers!

  • 2020-02-04 Kevin Bond

    I'm also curious about this. https://symfony.com/blog/ne... shows using "algorithm: auto". Which one should I use?

  • 2020-02-02 Gustavo

    Hi,
    Taking a look at my git repository, since Sep 29, 2019 I have selected the option 'algorithm: auto' in my security.yaml (previously had bcrypt),
    Should I write sodium instead of auto to migrate the password algorithm, or would the auto option still work?