You're storing passwords wrong
People seem to care about their passwords. Passwords are conceptually simple things related to the security of your accounts, and it's easy to picture them as a key giving access to your digital life. When companies fail to keep their users' passwords safe, Troy Hunt is waiting to add them to the big list of breached websites, and they can expect to be in the news and receiving lots of questions from their customers. As such, it's common for companies to follow a long list of best practices for keeping customer passwords secure — hash them with Argon2 or Bcrypt, use a salt and maybe a pepper, use constant-time comparisons, ...
These best practices are great, and the industry is in a much better state overall than 20 years ago when passwords were commonly stored in plain text, but they miss the strongest approach to securing passwords: making it impossible to read them!
Where best practices fall short
The most common approach to storing passwords is to dump them in a database, ideally in their own table. When a user logs in or changes their password, the application code will query the database and perform the necessary operations.
def login(user, password):
password_hash = DB.table("passwords").select("password").where_eq("user_id", user)
if safe_compare(password_hash, hash(password, SALT)):
return "Logged in"
return "Must be a hacker"
This approach appears to be secure. We've hashed our passwords, used a salt, avoided timing attacks, and are generally checking all of the boxes for best practices. However, it has one fatal flaw: if the application is hacked, it's trivial for the attacker to dump the entire table and put your company on the front page of HaveIBeenPwned. Because your application has permissions to read the entire password database, a bug anywhere in your application (potentially millions of lines of code) means it's game over for your users' passwords! If they didn't all use strong and unique passwords for your application (and they didn't...), you're now responsible for the breaches their other accounts are about to see.
Luckily, there's an easy way to avoid this failure mode. We can lean on a security engineer's best friend — compartmentalization.
Compartmentalizing our passwords
The root cause of this issue is that we've given the application much higher permissions than it needs. The innocuous code snippet above is assuming we have full read permissions to the database, but all we actually need is the ability to make a few specific queries. If we create an isolated new service (called PasswordVerification for this article) and only give it permission to access the password database, we can develop a simple protocol for the application to speak to PasswordVerification.
// Given a user and password, return true if that password is currently
// valid for the user, or false and a reason.
message ValidatePasswordRequest {
required int64 userid = 1;
required string password = 2;
}
message ValidatePasswordResponse {
required bool valid = 1;
optional string reason = 2;
}
// Given a user and a new password, set the user's password.
message SetPasswordRequest {
required int64 userid = 1;
required string password = 2;
}
message SetPasswordResponse {
required bool success = 1;
}
Using only these two API calls to talk to PasswordVerification, the application can still do whatever it needs to do around passwords. However, it no longer has high privileges over passwords that can be exploited when it's compromised, since we've moved those privileges to PasswordVerification — a compromised application can no longer read any passwords! Now, how do we secure that service?
While it can be hard to ensure the application isn't ever exploitable, it's much easier to ensure that for PasswordVerification due to the scope of its responsibilities. It doesn't do anything other than talk to the password database, so it will never make outbound network requests and doesn't need access to the public internet. We only need to support two API calls, so the code for the actual service will be very small — almost certainly under 1000 lines. While it's hard to make something impenetrable, the restricted surface area of PasswordVerification ensures it can be made orders of magnitude less exploitable than the main application.
Additionally, centralizing all of our logic in one trusted PasswordVerification service has other benefits. We can add rate-limiting, ensuring attackers inside our network have the same capabilities as those outside our network. We can add logging, knowing that a compromised application will be forced to use PasswordVerification and emit logs if a hacker wants to do anything with our passwords. In general, we now have one specific location to audit and alert on behaviours, giving us much more reliable and actionable information than if it's spread across our application.
Better practices
This approach is a simple way to make your password storage substantially more secure than the industry average.
It's generally simple to explain the benefits, and should require fairly minor refactoring if implementing in an existing codebase (and if it's not minor, you might want to reconsider the security of the existing system).
If you want to avoid ever being in the news for a password breach, putting a PasswordVerification service in front of your password database should be part of your implementation of password storage.