Password Hashing for Developers: bcrypt and argon2
Why you cannot use SHA-256 for passwords, what makes bcrypt and argon2 the right tools, and how to implement them correctly.
Storing passwords as plain text is obviously wrong. Storing them as SHA-256 hashes is also wrong — just less obviously. This tutorial explains why fast hashes are unsuitable for passwords and how to use the algorithms that are.
Why fast hashes are dangerous for passwords
SHA-256, MD5, and similar general-purpose hashing algorithms are designed to be fast. That is a feature for their intended use cases (file checksums, digital signatures) and a critical vulnerability for password storage.
A modern GPU can compute billions of SHA-256 hashes per second. If an attacker steals your database and finds a SHA-256 password hash, they can run every word in a dictionary plus millions of common mutations against it in minutes. Short passwords fall in seconds.
Password hashing algorithms are designed to be slow. They introduce a deliberate computational cost that makes brute-force attacks orders of magnitude harder. When you store a bcrypt hash, a legitimate login check takes 100–300 milliseconds — barely noticeable to a user, but catastrophic for an attacker who needs to test billions of candidates.
bcrypt
bcrypt has been the standard for password hashing since 1999 and is widely supported across languages and frameworks.
Python (bcrypt library):
import bcrypt
# Hash a password
password = b"the-users-password"
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password, salt)
# Store 'hashed' in the database
# Verify a password
def check_password(plain: bytes, hashed: bytes) -> bool:
return bcrypt.checkpw(plain, hashed)
Node.js (bcrypt library):
const bcrypt = require('bcrypt');
// Hash
const saltRounds = 12;
const hashed = await bcrypt.hash(plainPassword, saltRounds);
// Verify
const match = await bcrypt.compare(plainPassword, hashed);
The rounds / saltRounds parameter controls the cost. Each increment doubles the computation time. A value of 12 produces a hash that takes roughly 250ms to compute on modern hardware — fast enough for login, slow enough to deter brute force. Adjust upward over time as hardware gets faster; 14 is appropriate for sensitive applications.
bcrypt handles salt generation internally — you do not need to generate or store a separate salt. The salt is embedded in the hash output.
bcrypt has a 72-byte input limit. Passwords longer than 72 bytes are silently truncated. This is rarely a problem in practice but worth knowing.
argon2
Argon2 won the Password Hashing Competition in 2015 and is now the recommended choice for new applications. It is designed to be resistant to GPU-based attacks by using memory bandwidth as well as CPU — an attacker needs a large amount of RAM per parallel attempt, not just processing power.
Three variants exist: argon2d (faster, vulnerable to side-channel attacks), argon2i (slower, side-channel resistant), and argon2id (hybrid — the recommended default for password hashing).
Python (argon2-cffi library):
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=2, # number of iterations
memory_cost=65536, # memory in KB (64 MB)
parallelism=2 # threads
)
# Hash
hashed = ph.hash("the-users-password")
# Verify
try:
ph.verify(hashed, "the-users-password")
# returns True on success, raises exception on failure
except Exception:
# invalid password
pass
# Check if rehash is needed (parameters changed)
if ph.check_needs_rehash(hashed):
hashed = ph.hash("the-users-password")
# update stored hash
Node.js (argon2 library):
const argon2 = require('argon2');
// Hash
const hashed = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 2,
parallelism: 2,
});
// Verify
const match = await argon2.verify(hashed, password);
bcrypt vs argon2: which to choose
Use argon2id for new applications. It offers stronger guarantees against GPU attacks and is the current standard recommendation from OWASP and NIST.
Use bcrypt when you need compatibility with existing infrastructure, or when argon2 support is not available in your runtime environment. bcrypt remains secure and is not broken — it is a reasonable choice, just not the most modern one.
Never use MD5, SHA-1, SHA-256, or any general-purpose hash for password storage.
Rehashing on login
When you increase the cost parameter over time (as hardware gets faster), existing hashes in the database are still valid but use the old cost. A clean approach: after a successful login, check whether the stored hash used an outdated cost and silently rehash the password with the new parameters:
# After successful verification
if ph.check_needs_rehash(stored_hash):
new_hash = ph.hash(plain_password)
db.update_password_hash(user_id, new_hash)
This upgrades hashes passively as users log in, without requiring a password reset.
What to store
Store only the hash output. Do not store the plain password. Do not store the salt separately — both bcrypt and argon2 embed the salt in the hash string. The hash output is self-contained and is the only thing you need to verify future logins.
A bcrypt hash looks like:
$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
An argon2id hash looks like:
$argon2id$v=19$m=65536,t=2,p=2$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
Both contain the algorithm, parameters, salt, and hash in a single string. Feed the stored hash and the candidate password into the verification function — that is the entire verification step.
SysEmperor