Skip to content

Insecure password storage

CWE-916CWE-759CWE-257OWASP A02:2021Updated September 4, 20265 min read

Passwords should be stored with a hash function that is deliberately slow and uses a per-user salt: Argon2id, scrypt or bcrypt. Fast hashes such as MD5 and SHA-256 can be reversed at scale with modern hardware, and encryption is unsuitable because the key is always reachable somewhere.

Of all the data in your database, one category is the one you never need to be able to read: passwords. You only need to be able to establish whether someone entered the right one. That distinction, verifying rather than retrieving, determines what the storage should look like. This article explains why it so often goes wrong and how to fix it without troubling your users.

What is insecure password storage?

Insecure password storage means the stored form protects the original password insufficiently at the moment an attacker obtains the database. That is the assumption you have to work from: not whether the database will ever leak, but what happens when it does.

Three variants occur in practice. The most serious is storage in readable form, where the password sits literally in a column. Next comes encryption, which looks better but is not: encryption is reversible, so a key exists somewhere, and in practice it sits on the same server or in the same configuration. And finally, the most common: hashing with an algorithm unsuited to the purpose, such as MD5, SHA-1 or SHA-256, with or without a salt.

That last one needs explaining, because those functions are not “broken”. They are designed to be fast, and for verifying file integrity that is a virtue. For passwords it is a defect: an attacker with a stolen table can run billions of candidates through them with an ordinary graphics card. A password hash has to be slow, slow enough to be barely noticeable on a single login and crippling across billions.

How is insecure password storage exploited?

Vulnerable:

// Fast hash, fixed or missing salt
const crypto = require('node:crypto');

function storePassword(userId, password) {
  const hash = crypto.createHash('sha256').update(password).digest('hex');
  return db.query('UPDATE users SET hash = ? WHERE id = ?', [hash, userId]);
}

Two problems at once. SHA-256 is too fast, and there is no salt, so identical passwords get identical hashes. That last point means an attacker can see at a glance which users share a password, and that ready-made tables of precomputed hashes are immediately usable. A list of ten million common passwords can be compared against the whole database this way within seconds.

Safe:

const argon2 = require('argon2');

// Tune the parameters to your hardware: aim for roughly 0.5 seconds per hash
const OPTIONS = {
  type: argon2.argon2id,
  memoryCost: 19456,   // 19 MiB
  timeCost: 2,
  parallelism: 1,
};

async function storePassword(userId, password) {
  const hash = await argon2.hash(password, OPTIONS);   // salt is in the output
  await db.query('UPDATE users SET hash = ? WHERE id = ?', [hash, userId]);
}

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

  // Take the opportunity to migrate or strengthen straight away
  if (argon2.needsRehash(user.hash, OPTIONS)) {
    await storePassword(user.id, password);
  }
  return true;
}

Argon2id is deliberately slow and memory-intensive, which makes attacking it with graphics cards considerably more expensive. The salt is generated automatically and included in the output string, so you need no separate column for it. The needsRehash check is the detail that makes migrations painless: on every successful login it checks whether the stored hash still meets the current parameters, and if not, it is silently replaced.

If Argon2id is unavailable, scrypt and bcrypt are good alternatives. With bcrypt one point deserves attention: input longer than 72 bytes is truncated, so hash long passphrases with SHA-256 first before offering them to bcrypt.

If you store passwords in readable or encrypted form, assume every password in the database is compromised as soon as it leaks. Because people reuse passwords, such a leak also affects your users’ accounts at other services. That makes it reportable and puts your organisation in the position of having caused harm to others.

What is the impact of insecure password storage?

The severity is high to critical, which sets this finding apart from most others in this category. The reason is that the consequence only materialises during another incident, but is then complete.

If the database leaks, through SQL injection, a misconfigured backup, a stolen laptop or an employee with excessive rights, the storage form alone determines what an attacker gains from it. With Argon2id and sensible parameters, cracking a single strong password costs unaffordable computation time. With MD5 and no salt, the whole table is converted to readable passwords within a day.

The damage does not stop with you, either. Because password reuse is widespread, cracked passwords grant access to those same users’ email accounts, webshops and business systems. A breach at your organisation therefore becomes a problem for third parties, with the associated notification duty under the GDPR and reputational damage reaching beyond the incident. Unlike many vulnerabilities, there is also little to be done afterwards: a leaked hash cannot be taken back.

How do you detect insecure password storage?

This is a finding rarely visible from the outside; it surfaces during a source code review, a configuration assessment or a conversation with the development team. The question of which algorithm is used therefore belongs in the scope of a security assessment as a matter of course.

There are indirect signals, though. An application enforcing a maximum length of, say, sixteen characters or rejecting special characters probably does not treat the password as opaque input. A password reset function that emails the old password back proves it is stored retrievably, which is a directly demonstrable finding. A strikingly fast response to a login attempt can also indicate that no costly hash function is being run.

During a review the parameters are examined as well: a bcrypt with too low a cost factor or an Argon2 with minimal memory settings offers far less than it appears. Testers also check whether comparison is done in a timing-safe way and whether a migration path to heavier parameters exists. AssistSec covers these points in a code review or configuration assessment, because a penetration test from the outside has by definition limited visibility here.

How do you prevent insecure password storage?

  • Use Argon2id for hashing passwords, with scrypt or bcrypt as alternatives.
  • Tune the parameters to your hardware; aim for around half a second of computation per hash.
  • Use a unique, random salt per password; modern functions handle that themselves.
  • Never store passwords readable or encrypted, not even “temporarily” or in log files.
  • Hash long input with SHA-256 first when using bcrypt, because of the 72-byte limit.
  • Recompute the hash on every successful login once the parameters or algorithm have been strengthened.
  • Consider a pepper in a key vault as an extra layer on top of the hash.
  • Never email an existing password; work exclusively with a short-lived reset link.
  • Compare hashes with a timing-safe function and never log the entered value on a failed attempt.

Sources

Frequently asked questions

Why is a fast hash such as SHA-256 unsuitable?

Precisely because it is fast. Modern graphics cards compute billions per second, which lets an attacker with a stolen database try enormous numbers of candidates. A password hash should be deliberately slow and memory-intensive, so that every attempt has a measurable cost.

Must I keep the salt secret?

No, a salt does not have to be secret; it has to be unique per password. Its purpose is to prevent identical passwords getting identical hashes and to make precomputed tables useless. Modern functions such as bcrypt and Argon2id simply include the salt in the output string.

How do I migrate from an outdated algorithm?

Without inconveniencing users: on the next successful login, compute a new hash with the modern algorithm and replace the old one. For accounts that never return you can invalidate the old hash after a period and force a password reset.

What is a pepper and do I need one?

A pepper is a secret value you add to the password before hashing, kept not in the database but in a key vault or environment variable. If only the database leaks, the hashes are unusable. It is a useful extra layer, not a replacement for a good hash function.

Related articles

Press / to search · Esc