Storing Passwords

Store passwords as a deliberately slow salted hash — argon2id or bcrypt — and compare them in constant time.

Syntaxconst hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 })

A password must never be stored the way the user typed it. If someone copies your database, every account is open. Store a hash instead: a one-way function that turns the password into fixed-length bytes nobody can turn back.

Not any hash. sha256 is built to be fast, so a graphics card can test billions of guesses a second. Password hashing wants the opposite — a function that is deliberately slow and memory-hungry. Use argon2id first, bcrypt second. Node also ships scrypt in node:crypto, added in Node 10.5.0, which needs no package installed.

Cost factors

Each one takes parameters that set how slow it is. OWASP suggests argon2id with 19456 KiB of memory, 2 passes and 1 lane. The argon2 package calls those memoryCost, timeCost and parallelism, and defaults to 65536, 3 and 4. bcrypt has a single knob, the work factor, with a minimum of 10; each step up doubles the work. Pick the highest numbers your login route can afford, then raise them as hardware improves.

Salt

A salt is random bytes mixed into each user's hash, so two people with the same password store different bytes. argon2 and bcrypt generate the salt themselves and write it into their output string, so one database column holds the hash, the salt and the parameters together.

Never write your own

Do not chain hashes together, invent a pepper scheme, or compare with ===. Call the library's verify, or for raw digests crypto.timingSafeEqual, which takes the same time whether the first byte differs or none do.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Storing a new account's password when someone signs up.
  • Checking a login attempt without ever comparing plain text.
  • Raising the cost factor across a live user table as hardware gets faster.
  • Replacing an inherited MD5 or SHA-1 password column one login at a time.

More examples

argon2id at sign-up and at login

The shape almost every service needs: hash on the way in, verify on the way out, and nothing in between that touches the plain password. Note the argument order of verify — digest first, attempt second — because getting it backwards returns false for every correct password. Reach for this when you own the password form yourself rather than delegating to a provider.

Example · javascript
const argon2 = require('argon2');

// Sign-up. The returned string already contains the algorithm, the
// parameters and the salt, so one TEXT column stores all of it.
async function register(email, password) {
  const hash = await argon2.hash(password, {
    type: argon2.argon2id,  // the hybrid variant; the package's default
    memoryCost: 19456,      // KiB of memory per hash
    timeCost: 2,            // passes over that memory
    parallelism: 1,         // lanes
  });
  await pool.query(
    'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
    [email, hash],
  );
}

// Login. verify() takes the stored digest FIRST and the attempt second,
// reads the parameters back out of the digest, and compares safely.
async function login(email, password) {
  const { rows } = await pool.query(
    'SELECT id, password_hash FROM users WHERE email = $1',
    [email],
  );
  if (rows.length === 0) return null;
  const ok = await argon2.verify(rows[0].password_hash, password);
  return ok ? rows[0].id : null;
}

bcrypt, with its shorthand and its longhand

bcrypt is the right answer when argon2's native build is awkward in your deployment, since bcrypt also compiles from C but is older and more widely packaged. The two hash calls shown are equivalent; you will meet both forms in other people's code. The byte-length guard matters because bcrypt does not error on a long password, it quietly uses the first 72 bytes.

Example · javascript
const bcrypt = require('bcrypt');
const WORK_FACTOR = 12;

// LONGHAND: make a salt, then hash with it.
const salt = await bcrypt.genSalt(WORK_FACTOR);
const hashLong = await bcrypt.hash(password, salt);

// SHORTHAND: pass the number instead of the salt. bcrypt calls
// genSalt for you. These two are the same thing, and the stored
// string is in the same format either way.
const hashShort = await bcrypt.hash(password, WORK_FACTOR);

// Reject over-long input rather than letting bcrypt ignore the tail.
if (Buffer.byteLength(password, 'utf8') > 72) {
  throw new Error('password too long');
}

// compare() re-reads the work factor out of the stored hash, so old
// hashes made at a lower factor still verify after you raise it.
const ok = await bcrypt.compare(password, storedHash);

Upgrading the cost factor during login

You cannot re-hash a stored password on its own, because you do not have the plain text. Login is the one moment you do. This keeps an existing user table drifting upwards without a migration or a forced reset: active accounts move to the new parameters the next time they sign in.

Example · javascript
const argon2 = require('argon2');

const CURRENT = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };

async function loginAndMaybeRehash(user, password) {
  if (!(await argon2.verify(user.password_hash, password))) return false;

  // needsRehash compares the parameters baked into the stored digest
  // against the ones you pass. True means the hash is older and weaker.
  if (argon2.needsRehash(user.password_hash, CURRENT)) {
    const fresh = await argon2.hash(password, CURRENT);
    await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [fresh, user.id]);
  }
  return true;
}

Discussion

  • Be the first to comment on this lesson.