Storing passwords - the right and wrong ways

propelauthblog

propelauthblog

Posted on November 10, 2021

Storing passwords - the right and wrong ways

In this post, we'll walk through all the ways you can store passwords. We'll see the ideas and drawbacks behind each approach, and conclude with the current best way to store them.

In each case, the main question we want to answer is "What could an adversary do if they got access to our database?"

Approach 1: Store them in plaintext

// Using Sequelize for the examples
async function saveUser(email, password) {
  await DbUser.create({
    email: email,
    password: password,
  })
}

async function isValidUser(email, password) {
  const user = await DbUser.findOne({email: email});
  return user && password === user.password
}
Enter fullscreen mode Exit fullscreen mode

You've probably already heard that this is a bad idea. If anyone ever gets access to our database, they have immediate access to everyone's passwords. We didn't slow them down at all.

While we tend to think of database access as an attack, it might not even be a malicious thing. Maybe an employee needed read-only access to the DB, and they were given access to the user table too. By storing the passwords in plaintext, it's hard to truly protect our users.

Approach 2: Encrypt them

const aes256 = require('aes256');
const key = 'shhhhhhhhh';

async function saveUser(email, password) {
  const encryptedPassword = aes256.encrypt(key, password);
  await DbUser.create({
    email: email,
    password: encryptedPassword,
  })
}

async function isValidUser(email, password) {
  const user = await DbUser.findOne({email: email});
  if (!user) return false;

  // Decrypt the password from the DB and compare it to the provided password
  const decryptedPassword = aes256.decrypt(key, user.password);
  return decryptedPassword === password
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately for us, encrypted data can be decrypted. If an attacker gets access to a key (which doesn't seem unreasonable if they are getting access to our DB), then we are basically back to the plaintext case. This is certainly better than the plaintext case, but we can do better. What if we stored the passwords in a format that cannot be reversed?

Approach 3: Hash them

const crypto = require('crypto');

async function saveUser(email, password) {
  await DbUser.create({
    email: email,
    password: sha256(password),
  })
}

async function isValidUser(email, password) {
  const user = await DbUser.findOne({email: email});
  return user && sha256(password) === user.password
}

function sha256(text) {
  return crypto.createHash('sha256').update(text).digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

The advantage of using a hash function over encryption is that the function cannot be reversed. This should mean that the password cannot be recovered from the database.

We can only tell that someone provided a valid password by hashing their input and checking if the hashes match.

This sounds perfect so far, however, a clever attacker can precompute the sha256 hashes of a lot of common passwords. If an attacker got access to the DB and saw someone with password hash 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8, they could quickly figure out that person chose the most common password... password

Large precomputed tables of common passwords and short strings exist, so we need someway to counteract that.

Approach 4: Salt our passwords

A "salt" is random data that we add on to our password.

const crypto = require('crypto');

async function saveUser(email, password) {
  // The salt is randomly generated each time
  const salt = crypto.randomBytes(64).toString('hex')

  await DbUser.create({
    email: email,
    salt: salt, // The salt is stored in the table
    password: sha256(salt, password),
  })
}

async function isValidUser(email, password) {
  const user = await DbUser.findOne({email: email});

  // We use the salt loaded from the DB to verify the password
  return user && sha256(user.salt, password) === user.password
}

function sha256(salt, text) {
  return crypto.createHash('sha256').update(salt + text).digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

A few important things to note:

  • There is not one global salt. Each user gets their own salt. A global salt would still allow an attacker to precompute password hashes starting with that global salt.
  • It doesn't matter how you combine the salt and password. In this case we just prepended it.

Salting is a really powerful technique. A user who chose the password password will no longer get the hash 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8, but will instead get the hash of a much larger string that ends with password.

We're almost done, there's just one more issue we need to deal with. SHA256 hashes can be computed pretty quickly. If I am an attacker with access to your database, I can carry out targeted attacks against specific people using their salts.

This is done by computing hashes for a specific users salt with a dataset of common passwords. A good password will still be very difficult to crack, but the attacker can use the salts to relatively quickly find people with weak passwords.

What if we could intentionally make our hashing algorithm more difficult to compute?

Approach 5: Use a modern password hashing algorithm

According to OWASP, Argon2id, bcrypt, scrypt, and PBKDF2 are all applicable in different scenarios.

const bcrypt = require('bcrypt');

// bcrypt configuration
const SALT_ROUNDS = 10;

async function saveUser(email, password) {
  // The salt is stored in the passwordHash
  const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);

  await DbUser.create({
    email: email,
    passwordHash: passwordHash
  })
}

async function isValidUser(email, password) {
  const user = await DbUser.findOne({email: email});
  return user && await bcrypt.compare(password, user.passwordHash)
}
Enter fullscreen mode Exit fullscreen mode

A key way in which modern password hashing algorithms differ from something like sha256 is that their performance can be tuned.

bcrypt for example, has a "work factor" parameter. A higher work factor means that it takes longer to compute the hash of a password. A user trying to log in will have a slightly slower experience, but an attacker trying to precompute password hashes will too.

This ends up solving a lot of our issues. An attacker with access to our database cannot reverse the passwords to their original form. They cannot precompute lookup tables to easily find users with simple passwords. And if they want to guess someone's password, we have made the guessing process intentionally slower, so it requires more time and resources.

Modern password hashing algorithms still use salts, too. They actually embed the salt in their result, so you don't need a separate salt column in your database.

How do I configure my password hashing algo?

These algorithms are great, but they do have some parameters that need to be set. A good place to start is OWASP's guide on Password Storage which has recommendations for parameters.

Defense in Depth

While we have covered best practices for actually storing the password, to further protect users you should also consider techniques like breached password detection to stop users from using easily guessable passwords.

The code snippets above were simplified for readability, but they are also vulnerable to a simple timing attack. You can read more about protecting yourself from that here.

Conclusions

  • Always use a modern hashing algorithm and follow OWASP's guide to help configure it.
  • Never store passwords in any reverse-able format
  • In the case of a data breach, a good password is your user's best defense. Techniques like breached password detection can also help mitigate some of these issues.
💖 💪 🙅 🚩
propelauthblog
propelauthblog

Posted on November 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related