I have a confession to make: I've ignored a Really Bad Password Form on an inherited web application for about at least a decade too long.
I'm not proud, but every time I considered changing the password mechanism to something more modern (and more secure), decision paralysis would set in...in great part due to the design challenges I anticipated in quietly upgrading this for users of the app in question.
How Bad Are We Talking, Exactly?
Well, in true "Minnesotan" fashion I'll say unequivocally that "It could definitely be worse." We're not talking about plaintext passwords over HTTP or anything like that. But it wasn't much better than a really bare-bones minimum.
At the time of this "upgrade," the system required a 10+ character complex password, but ultimately relied on basic and non-unique (read: MD5) hashing to obfuscate the password. MD5 was the real killer in question. About seven years ago we upgraded other aspects of the system to require more complex passwords and the process still lingers in my memory: we had a number of weird "problems" with that rollout...primarily due to the demographics of the app users but also due to a general misunderstanding of why more complex passwords are/were of value and necessity. It was a little rough for a couple of weeks as folks got used to the New World Order. Thankfully the system doesn't store or use sensitive private data. The system also has a well-secured application database so any breach exposing hashed passwords would be unlikely.
Based on the last upgrade, any further improvements to the password system would need to be handled as gently and quietly to the end user.
Why Now, Finally?
After the application moved to a newer and more modern set of hosts, we have better built-in tools at our disposal for cryptographic operations. This host migration cleared a path to leveraging features baked into PHP 7.4 and beyond. Keep in mind, this system was designed and implemented in the pre-PHP 5.5 days. As I plan to migrate this application to PHP 8 later this year, it was time to bite the bullet and clear out this old issue once and for all.
"Ugh, This is Going to Suck"
...said I, every time I thought about it. Since pretty much everything else I write or implement these days uses OAuth or some other mechanism for the password/authentication piece, I hadn't really built anything with a regular old user/pass system in a long time. So I was always (as it turns out) over-thinking the effort involved in this. Until I did some basic Googling on the subject.
As it turns out, PHP has for some time supported the two key functions: password_hash
and password_verify
. Better yet, since the default algorithm is bcrypt
with a random password salt value, I didn't have to write any new stuff to make a basic switch!
In fact, at its most basic level, the changes were as simple this (code simplified for clarity):
$passHashed = md5($passValue);
to
$passHashed = password_hash($passValue, PASSWORD_DEFAULT);
Further, validating a password at login changed from:
if (md5($passValue) != getPassFromDb($user)) {
to
if (false === password_verify($passValue, getPassFromDb($user))) {
As it turns out, the basic change did not actually suck at all.
Pass the Pepper, Please
While I was at it, I figured it would be best to add a pepper value to the already randomly-salted password. The pepper is an application-side hard-coded value, which in this application is super easy to have sitting quietly out of the way from the remainder of the source.
Adding a pepper to the $passValue
being handed off to password_hash
or password_verify
was also super easy:
$pepperedPass = hash_hmac("sha256", $passValue, $pepperValue);
$passHashed = password_hash($pepperedPass, PASSWORD_DEFAULT);
Similarly, validating the password at login changes slightly:
$pepperedPass = hash_hmac("sha256", $passValue, $pepperValue);
if (false === password_verify($pepperedPass, getPassFromDb($user))) {
What's really beautiful about all of this is that with those couple of line changes, even the same password once hashed will have wildly different values in the database (and/or at each generation), which seems like dark magic. But that is by design and if you're looking for a fun little rabbit hole of learning I'd totally recommend looking at how it all works. The short version is that enough detail about the hash is stored in the output of password_hash
for password_verify
to accurately verify the value. Having a peppered input value significantly strengthens this process.
How to Quietly Roll This Out?
I'd easily solved the actual mechanics of switching to a new password mechanism, but how could I roll this out quietly to an app user base of several thousand...and do so without them even knowing it (or having to take time-based action)?
I settled on adding a field to the user table in the database, where other attributes are stored (like force_password_reset
and last_change
). I could then easily add a quick query for the freshly-minted pass_is_upgraded
value during the login sequence and take appropriate action, specifically:
if (isUserPasswordUpgraded($user)) {
// Using a newer style password; just verify
$pepperedPass = hash_hmac("sha256", $passValue, $pepperValue);
if (false === password_verify($pepperedPass, getPassFromDb($user))) {
// Re-route to failed login info removed for clarity
}
} else {
// Using older password; verify and quietly upgrade if auth was successful
if (md5($passValue) != getPassFromDb($user)) {
// Re-route to failed login info removed for clarity
}
// Upgrade password
$pepperedPass = hash_hmac("sha256", $passValue, $pepperValue);
$passHashed = password_hash($pepperedPass, PASSWORD_DEFAULT);
// Update DB record query request removed for clarity
}
After several various attempts to break things in the dev environment, I decided this was sufficient to get folks into the new mechanism without actually breaking anything...and better yet without them even knowing it was being handled in a much better way.
Finding the Proper Time
I didn't want to roll this out to the production environment quietly in the middle of the night, but I also didn't want to do it at the peak times of a given week. Weirdly enough, this actually meant planning for a Friday deployment to production. There are enough folks actively using the system on the evenings and weekends where I wouldn't have to wait long to see the cumulative count of pass_is_upgraded
start to tick upward in the DB, but light enough to address and quickly fix anything should it somehow go sideways.
This change also only affects the authentication process itself, so folks who were already authenticated and active were not impacted by the change (it doesn't fiddle with anyone's session).
Within 10-15 minutes I'd seen enough users start to have the expected pass_is_upgraded
value (in conjunction with the real-time Google Analytics data) to know things were behaving as designed...and quietly making things Better.
Looking Back, Looking Ahead
In hindsight, I'm super embarrassed to have not made this simple change a long time ago. That's squarely on me. At the same time, I'm glad I was able to make this super-meaningful change quietly and successfully...with a little bit of planning.
The app in question has a large user base, but some folks are inactive or infrequent users. This was an important consideration in making both mechanisms work...indefinitely, or at least until enough data is available to inform future decisions (such as invalidating passwords or accounts).
To that end, having the basic data of the pass_is_upgraded
database flag will be important moving forward. We can look at the number of "unchanged" accounts in 3, 6, 12 months and, in conjunction with other flags figure out if there's a necessary communication angle...or a more tough stance to take by way of forcing password resets or administratively marking accounts as inactive. Ideally we will hit a point in the future where the "bridge" of quietly converting can be deprecated and removed...but only time will tell.
The real takeaway is that while I'm quite embarrassed I didn't make this change a decade ago, I'm happy I finally did it. I share the story with you as a reminder that even if when Past You made stupid decisions, Current You can still take corrective action, and it might be simpler than you think. Ideally before the adage of "Broken gets Fixed; Shoddy lasts Forever" hits.
Good luck!