Migrate to a More Secure Password Storage System

It seems every week another high-profile site has fallen prey to an exploit that reveals the email addresses and passwords of their users. Every time it happens, the programming community rolls their collective eyes. How could someone store passwords in plaintext or using a simple hash? I shudder to think how many databases are floating around out there with these practices still in place.

Some of the blame undoubtedly lands on junior or amateur developers who just don’t have experience but were still able to get the job done; however, I would wager the lion’s share of the blame goes to companies unwilling to address tech debt. The login and signup systems have been working great for years, why invest time in this upgrade? It isn’t until they have an embarrassing leak that they learn the answer to that question.

The time investment in migrating isn’t as great as many may think and it can easily be done transparently (as users login). The majority of companies I’ve worked for in the last decade have had this problem and I’ve taken a similar approach each time to migrate the password storage. The legacy implementations have varied from plaintext, unsalted hashes, salted hashes, and salted and peppered hashes. None of the above are good.

Flavorless Hashes

If you are using something like plain MD5 or SHA1, you probably have some code similar to this sitting in your login function (ignore the obvious lack of validation and user input sanitization, this is to serve as a bad example that needs to be replaced):

$username = $_REQUEST['username'];
$passwordHash = md5($_REQUEST['password']);

$statement = $pdo->prepare('SELECT * FROM `user` WHERE `username` = :username AND `password` = :password');
$statement->execute([':username' => $username, ':password' => $passwordHash]);

Simple enough. We aren’t storing the actual password in the database and it’s obfuscated by a hash. It’s also vulnerable to a nasty rainbow table attack.

Peppered Hashes

A similar and slightly more secure approach is to use pepper. Pepper is a long string stored in the codebase that is concatenated to the input string. The provides better protection against pre-generated rainbow tables, but if the attacker got their hands on both the codebase and the database it loses all value.

$pepper = 'iXGowbznFl8hiD3JNb2Q';
$username = $_REQUEST['username'];
$passwordHash = md5($_REQUEST['password'].$pepper);

$statement = $pdo->prepare('SELECT * FROM `user` WHERE `username` = :username AND `password` = :password');
$statement->execute([':username' => $username, ':password' => $passwordHash]);

Salted Hashes

Salt is similar to pepper. Instead of using a common string from the codebase, we’ll store the salt alongside the password in the user record. This changes the logic a bit because we have to pull the record instead of passing the password as part of the hash.

$username = $_REQUEST['username'];
$password = $_REQUEST['password'];

$statement = $pdo->prepare('SELECT * FROM `user` WHERE `username` = :username');
$statement->execute([':username' => $username]);
$result = $statement->fetch(PDO::FETCH_ASSOC);

if (md5($result['salt'].$password) === $result['password']) {
    // The passwords match
} else {
    // The passwords do not match
}

Much better, but still has failings. The biggest is how cheap and fast an attack can be against this dataset. Modern hardware can blast through an attack in no time.

Bcrypt: Our Fix

Edit per discussion: The example uses default cost. At the time of this writing, default cost is low and you should increase it. The examples reflect that.

Unpeppered bcrypt is my go-to replacement. You can undoubtedly get a higher level of protection by peppering your bcrypt and we’ll explore that later. For now, I’ll focus on the quick solution to get us 90% of the way there.

Like the salted hash example from above, we’ll have to pull the record from the database first. From there we’ll apply some PHP built-ins to make the process simple. This will also do the job of migrating our users as they login:

$username = $_REQUEST['username'];
$password = $_REQUEST['password'];

$statement = $pdo->prepare('SELECT * FROM `user` WHERE `username` = :username');
$statement->execute([':username' => $username]);
$result = $statement->fetch(PDO::FETCH_ASSOC);

if (password_verify($password, $result['password'])) {
    // The passwords match and have been migrated already!
} else if (md5($result['salt'].$password) === $result['password']) {
    // The passwords match, but this user is not migrated
    $statement = $pdo->prepare('UPDATE `user` SET `password` = :newHash WHERE `id` = :id');
    $statement->execute([
        ':id' => $result['id'], 
        ':newHash' => password_hash($password, PASSWORD_DEFAULT, ["cost" => 14])
    ]);
} else {
    // The passwords do not match
}

You’ll also need to update your signup functionality and any script or panel that deals with resetting the password (either by the user or an administrator).

Please refer to the PHP documentation for implementation details: password_hash and password_verify

Bonus Points: Peppered Bcrypt

Edit per discussion: Don’t pepper bcrypt. This one’s a bit more controversial, but it’s a pointless exercise as usually code and database are plundered together. (More to the point, usually webservers are successfully attacked, database servers are typically not internet facing, so your sourcecode is plundered first, along with that pepper. Also Kerckhoffs’s principle applies in this case, with the password playing the part of the key, meaning the pepper provides no security.)

Long story short, peppering bcrypt is not going to provide more security. Rather it gives the illusion of security because the odds of them having your database means they already have the pepper you so dutifully applied.

To answer a question from the /r/php subreddit, you could apply pepper to bcrypt like so (using our last example as a jumping off point). Bear in mind, in this example we’re assuming the existing passwords are NOT using pepper and this is something we are applying moving forward:

$pepper = 'nhBlJBqeEgdtaIgIdN7y';
$username = $_REQUEST['username'];
$password = $_REQUEST['password'];

$statement = $pdo->prepare('SELECT * FROM `user` WHERE `username` = :username');
$statement->execute([':username' => $username]);
$result = $statement->fetch(PDO::FETCH_ASSOC);

if (password_verify($password.$pepper, $result['password'])) {
    // The passwords match and have been migrated already!
} else if (md5($result['salt'].$password) === $result['password']) {
    // The passwords match, but this user is not migrated
    $statement = $pdo->prepare('UPDATE `user` SET `password` = :newHash WHERE `id` = :id');
    $statement->execute([
        ':id' => $result['id'], 
        ':newHash' => password_hash($password.$pepper, PASSWORD_DEFAULT,        ["cost" => 14])
    ]);
} else {
    // The passwords do not match
}