InTab

Blog

Storing passwords safely: Argon2id vs bcrypt vs scrypt vs PBKDF2

By Uxair Ijaz · 2026-10-24 · 7 min read

Sooner or later, every database leaks. A backup goes missing, a server is misconfigured, a dependency is compromised. What happens to your users then depends almost entirely on one decision: how you stored their passwords. Stored properly, a leaked password table is a nuisance. Stored badly, it is millions of real passwords, many of them reused on email and banking accounts.

This guide explains what a password hash must do, compares Argon2id, bcrypt, scrypt and PBKDF2, gives the settings OWASP recommends, and covers the parts people forget: salts, peppers, upgrades and migration. You can try every algorithm in the Password Hasher, which runs in your browser.

Why not just SHA-256?

SHA-256 is an excellent hash, and exactly the wrong tool for passwords, because it is fast. A single modern graphics card computes billions of SHA-256 hashes per second. An attacker with your leaked table simply hashes every word in a list of common passwords, every variation and every combination, and compares the results with yours. Most real-world passwords fall in minutes.

A password hash has three jobs that a general-purpose hash does not:

  1. Be slow on purpose, with a cost you can raise as hardware improves.
  2. Be expensive in memory, so attackers cannot run thousands of guesses in parallel on cheap GPU or ASIC cores.
  3. Use a unique salt per password, so identical passwords produce different hashes and one precomputed table cannot crack everyone at once.

If you are unsure which is which, Hashing vs encryption covers the basics. And never encrypt passwords: anything that can be decrypted can be decrypted by an attacker who finds the key.

The four algorithms

Algorithm Year Memory-hard Recommended settings (OWASP) Use it when
Argon2id 2015 Yes m = 19 MiB, t = 2, p = 1 Default choice for new systems
scrypt 2009 Yes N = 2¹⁷, r = 8, p = 1 Argon2id is unavailable
bcrypt 1999 Slightly cost 10 or more Existing systems, or frameworks that default to it
PBKDF2 2000 No 600,000 iterations (SHA-256), 210,000 (SHA-512) FIPS-140 compliance is required

These are the defaults the Password Hasher loads, and OWASP defaults resets to them.

Argon2id: the default

Argon2 won the Password Hashing Competition in 2015 and is standardised in RFC 9106. It has three variants:

  • Argon2d resists GPU cracking best, but its memory access depends on the password, which can leak through side channels.
  • Argon2i avoids that leak but is weaker against some GPU attacks.
  • Argon2id does a pass of each and is the variant to use for passwords. The other two exist for special cases.

It has three settings:

  • Memory (m, in KiB): how much RAM each hash needs. This is what makes GPU attacks expensive.
  • Passes (t): how many times it works through that memory.
  • Lanes (p): how many threads can share the work.

OWASP's minimum is 19 MiB, 2 passes and 1 lane. Its other combinations are equally strong, trading memory against passes: 46 MiB with 1 pass, or 12 MiB with 3. More memory is generally better than more passes.

scrypt

scrypt was the first widely used memory-hard function and is still a sound choice. N is the cost (a power of two), r the block size and p the parallelism; memory use is roughly 128 × N × r bytes, so N = 2¹⁷ and r = 8 need 128 MiB. It is a good fallback when your platform has scrypt but not Argon2id, which is the case in some standard libraries.

bcrypt

bcrypt has protected passwords for over 25 years and is still acceptable. It uses a little fixed memory (4 KiB), so it is weaker against custom hardware than Argon2id or scrypt, but far better than any fast hash. The cost is a power of two: each step up doubles the work. Use at least 10, and more if your servers can take it.

It has one sharp edge: bcrypt only reads the first 72 bytes of the password. Characters after byte 72 are silently ignored, and non-Latin characters take two to four bytes each, so the limit arrives sooner than it looks. The Password Hasher refuses such passwords instead of quietly truncating them. If you must support long passphrases with bcrypt, a common workaround is to hash the password first with HMAC-SHA-256 and Base64-encode the result before passing it to bcrypt. Base64-encode it, because raw hash bytes can contain zero bytes, which some bcrypt implementations treat as the end of the string. Better still, use Argon2id.

PBKDF2

PBKDF2 repeats HMAC thousands of times. It is not memory-hard, so GPUs crack it far more efficiently than the others, which is why the recommended iteration counts are so high. Choose it only when a compliance regime such as FIPS 140 requires approved primitives.

Reading a hash string

Good libraries do not return bare hash bytes. They return a PHC string that records everything needed to verify the password later:

$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Kv6c3x9ZRbFJ2Tpq3mXc8F1dV0oS+gH4z5cY7wB2nQk
Part Meaning
argon2id Algorithm
v=19 Argon2 version (0x13)
m=19456,t=2,p=1 Memory in KiB, passes, lanes
c29tZXNhbHQ… The salt, Base64
Kv6c3x9… The hash, Base64

bcrypt has its own older format, $2b$10$ followed by a 22-character salt and a 31-character hash. Store the whole string in one column, VARCHAR(255) or TEXT. Because the settings travel with each hash, you can raise them later without breaking existing users. The Password Hasher detects the algorithm from the prefix when you verify.

Salts and peppers

A salt is a random value, at least 16 bytes, generated fresh for every password and stored inside the hash string. It is not secret. Its job is to make two users with the password Summer2026! end up with completely different hashes, which defeats precomputed rainbow tables and forces an attacker to crack each hash separately. Every algorithm above handles salts for you; never reuse one or build your own.

A pepper is a single secret value added to every password, stored outside the database: in a secrets manager, an HSM or environment configuration. If an attacker steals only the database, the pepper makes every hash uncrackable. Apply it as HMAC(pepper, password) before hashing, or encrypt the finished hash with it. Peppers are optional extra defence and add operational work: if you lose the pepper, every user must reset their password.

Choosing the cost

Settings are a trade-off between security and server load. Every login runs one hash, so a slow hash also slows your login endpoint and makes it an easier target for denial-of-service.

  1. Start from the OWASP values above.
  2. Measure on your production hardware, not a laptop. Many teams aim for roughly a quarter to half a second per hash.
  3. Check memory under peak load: 50 concurrent logins at 19 MiB each need about 1 GiB.
  4. Rate-limit login attempts, so attackers cannot use your servers to hash for them.
  5. Revisit the settings every year or two as hardware gets faster.

Upgrading without forcing resets

Because each hash records its own settings, you can upgrade gradually:

async function login(user: User, password: string) {
  if (!(await verify(user.passwordHash, password))) return false;
  // The password is correct and we have it in plain text, briefly: a chance to upgrade the hash
  if (needsRehash(user.passwordHash, CURRENT_SETTINGS)) {
    user.passwordHash = await hash(password, CURRENT_SETTINGS);
    await save(user);
  }
  return true;
}

Users who log in get the stronger hash automatically. Those who never return keep the old one until you decide to expire it.

Migrating from MD5 or SHA-1

Waiting for logins leaves the weak hashes of inactive users exposed. Instead, wrap them now: compute argon2id(old_md5_hash) for every row, store it with a marker, and verify those users with argon2id(md5(password)). On their next login, replace the wrapped hash with a normal argon2id(password). Every row is protected from the day you run the migration.

Mistakes to avoid

  • Fast hashes (MD5, SHA-1, SHA-256, even with a salt) for passwords.
  • Encrypting passwords instead of hashing them.
  • A shared or hard-coded salt.
  • Comparing hashes with ==. Use the library's verify function; it compares in constant time.
  • Low maximum lengths. Allow at least 64 characters so people can use passphrases, and cap it at a sensible limit, such as 1,000, to stop huge inputs being used for denial-of-service.
  • Silent truncation. If you use bcrypt, handle the 72-byte limit explicitly.
  • Logging passwords in request logs, error reports or analytics.
  • Writing your own scheme. Use a maintained library for your language.

Try it

Open the Password Hasher:

  1. Choose Argon2id (recommended), type a password and hash it. Hash it again and see a different result: that is the salt at work.
  2. Raise Memory KiB or Passes and notice how much longer it takes.
  3. Switch to Verify, paste the password and the hash, and check that it matches.

For strong passwords and passphrases, use the Password Generator. For bcrypt on its own, there is a dedicated Bcrypt tool.

Tools in this article

  • Password Hasher: Hash and verify passwords with Argon2id, bcrypt, scrypt or PBKDF2 (OWASP defaults).
  • Bcrypt: Hash passwords with bcrypt and verify hashes.
  • Password Generator: Generate strong random passwords, passphrases and PINs.