15.11 Secure Password Storage
Table of Contents
Why Password Storage Is Critical
If an attacker steals your user database, they must not be able to log in as your users. This is the whole purpose of secure password storage.
Insecure approaches like storing plain text passwords or weakly “encrypted” versions expose users to account takeover on your site and also on other sites where they reuse passwords.
This chapter focuses on how to store passwords safely on the backend side, not on how users choose passwords or how authentication flows work in general.
Goal: Even if an attacker gets a full copy of your database, it should be very hard and slow to recover users' original passwords.
We will cover what not to do, what the modern best practices are, and how to implement them in a typical backend application.
Bad Ways to Store Passwords
Before looking at the right solution, it helps to see clearly what is wrong. Many security incidents start with one of these patterns.
Plain Text Passwords
Storing passwords directly, for example in a users table:
| id | password | |
|---|---|---|
| 1 | alice@example.com | secret123 |
| 2 | bob@example.com | p@ssw0rd |
This is the worst possible approach.
Problems:
- Any database breach exposes every password immediately.
- Database admins or logs may reveal passwords.
- Developers may accidentally print passwords during debugging.
- Many users reuse passwords across sites, so your breach can be used to attack bank, email, and social media accounts.
Never store passwords in plain text. There is no exception, no temporary workaround, and no “internal system only” justification.
Reversible Encryption
Sometimes developers think: “I will encrypt the password with AES or another cipher, then store it.”
Example:
| id | enc_password | |
|---|---|---|
| 1 | alice@example.com | AES_ENC('secret123', 'mysecret') |
Drawbacks:
- The server must keep a decryption key to verify passwords.
- If attackers steal both the database and the key (which is common in full compromise), they can decrypt all passwords.
- Application bugs or logs may accidentally expose decrypted passwords.
Encryption can be useful for some sensitive data such as credit card tokens when you must recover the original data. For passwords, you never need the original, you only need to verify a match.
Simple Hashing (MD5, SHA1, SHA256, etc)
A better idea is: “We do not need the plain password, we can store a hash.”
Example:
hash = SHA256("secret123")and store:
| id | password_hash | |
|---|---|---|
| 1 | alice@example.com | ef92b778... (SHA256 of secret123) |
During login, you compute hash(candidate_password) and compare.
This is better than plain text, but still not good enough:
- No salt means same hash for same password
If both Alice and Bob use “secret123” their hashes are identical. Attackers can see which users share passwords.
- Fast to compute
Hash functions like MD5 or SHA256 are designed to be very fast. This is an advantage for file integrity, but a disaster for passwords: attackers can try billions of guesses per second on modern GPUs.
- Vulnerable to precomputed attacks (rainbow tables)
Attackers can precompute hashes for many common passwords and just look up matches.
Plain cryptographic hash functions like MD5, SHA1, SHA256, SHA512, or even modern SHA-3 must not be used directly to store passwords.
Hashing vs Encryption
It is important to distinguish between hashing and encryption, since they solve different problems.
Hashing
- One-way function: $H(x)$.
- Given $H(x)$, it is infeasible to find $x$.
- Used for verification, not recovery.
- Example use for passwords: store
H(password)and compare.
You never decrypt a hash. You only compute the hash of the candidate password and check if it matches.
Encryption
- Two-way function: $E_{k}(x)$ and $D_{k}(E_{k}(x)) = x$.
- Requires a secret key $k$ for both encryption and decryption (symmetric) or separate keys (asymmetric).
- Used when you need to recover the original data.
For passwords, you never need to recover the original. If you think you do, your design is wrong.
Passwords must be hashed, not encrypted. Hashes must be slow and salted, using algorithms specifically designed for password storage.
Salting Passwords
A salt is a random value that is different for each user and is combined with the password before hashing. The salt is not secret and is stored alongside the hash.
Why Salts Are Needed
Imagine two users choose the same password “secret123”:
Without salt:
hash1 = SHA256("secret123")
hash2 = SHA256("secret123")
# hash1 == hash2With salt:
salt1 = random_bytes()
salt2 = random_bytes()
hash1 = SHA256(salt1 + "secret123")
hash2 = SHA256(salt2 + "secret123")
# hash1 != hash2Benefits:
- Same passwords have different hashes
Attackers cannot see which users share passwords.
- Prevents precomputed rainbow tables
For an attacker to use precomputed hashes, they would need a separate table for each possible salt, which is not practical if salts are large and random.
- Per-user protection
Even if an attacker cracks one password, they do not automatically obtain other users’ passwords.
Salt Generation and Storage
Typical pattern:
- Generate a random salt for each user when they set a password.
- Store it in a separate column or embedded in the hash string.
Example table:
| id | salt | password_hash | |
|---|---|---|---|
| 1 | alice@example.com | e0f3e5c2... (16 or 32 random bytes) | 8a1b24c5... (hash of salt + password) |
At login:
- Retrieve
saltandpassword_hashfor the user. - Compute
hash(salt + candidate_password). - Compare the result with the stored
password_hash.
In practice, you rarely do this manually. Proper password hashing algorithms and libraries manage salts for you, as we will see with bcrypt and Argon2.
Password Hashing Algorithms
For password storage, you want functions that are:
- Slow to compute, to make large-scale guessing expensive.
- Configurable, so you can increase cost as hardware improves.
- Ideally memory-hard, to make large parallel attacks more difficult.
These are called password hashing functions or key derivation functions.
Common choices:
| Algorithm | Type | Status |
|---|---|---|
| bcrypt | Password hash | Widely used and battle tested |
| scrypt | Password hash | Memory hard, less common now |
| PBKDF2 | KDF | Used in many standards |
| Argon2 | Password hash | Modern, memory hard, recommended |
Use a dedicated password hashing function like bcrypt, Argon2, scrypt, or PBKDF2, not a raw hash like SHA256.
We will focus on bcrypt and Argon2, which are most common for modern backends.
bcrypt
bcrypt is one of the oldest and most widely used password hashing algorithms. Most languages and frameworks provide mature, vetted implementations.
Properties
- Built-in per-password salt.
- Configurable cost factor (work factor).
- Produces a string that usually encodes:
- Algorithm identifier
- Cost parameter
- Salt
- Hash
Example bcrypt hash:
$2b$12$QeWZWbCwUGIFDNb4L3IFnOm24eG6PzwSUIvwrJqTkOUoUTr0MN/BKBreakdown:
$2b$bcrypt version.12$cost factor (work factor) is 12.- Next 22 characters: salt.
- Last part: hash.
You do not manually extract these parts. The library handles it.
bcrypt Workflow Example
Let us imagine a backend API using Python (syntax is similar in other languages).
Registration
- User sends:
{
"email": "alice@example.com",
"password": "Secret123!"
}- Backend:
import bcrypt
password_bytes = "Secret123!".encode("utf-8")
salt = bcrypt.gensalt(rounds=12) # cost factor 12
hash_bytes = bcrypt.hashpw(password_bytes, salt)
hash_str = hash_bytes.decode("utf-8")- Store only
hash_strin database:
| id | email | password_hash |
|----|-------------------|-----------------------------------------------------|
| 1 | alice@example.com | $2b$12$QeWZWbCwUGIFDNb4L3IFnOm24eG6PzwSUIvwrJqTk... |
Never store the plain password or the salt separately. For bcrypt, the salt is inside the hash string.
Login
- User sends:
{
"email": "alice@example.com",
"password": "Secret123!"
}- Backend:
stored_hash = row.password_hash.encode("utf-8")
candidate = "Secret123!".encode("utf-8")
is_correct = bcrypt.checkpw(candidate, stored_hash)- If
is_correctisTrue, authentication can proceed.
Note:
- You never need to know or handle the salt separately.
- You never compare hashes manually with
==. You let the library do the check, which can also avoid subtle timing issues.
Cost Factor
The cost factor controls how slow the hashing is. For bcrypt, the work is roughly proportional to $2^{cost}$.
- Cost 10: relatively fast.
- Cost 12: slower, more secure.
- Cost 14: even slower.
You need to balance security and performance:
- Try hashing with different costs and see how long one hash operation takes on your server hardware.
- Aim for something like 100 ms per hash (this is not a strict rule, but a common starting point).
- For example, if cost 12 is about 100 ms and cost 13 is 200 ms, you might choose 12.
Later, when hardware is faster, you can increase the cost for new passwords and rehash existing ones after users log in.
Argon2
Argon2 is the winner of the Password Hashing Competition and is considered a modern, strong choice. It is memory hard, which means it requires a significant amount of RAM per hash, making large GPU-based attacks more expensive.
Variants:
- Argon2id is generally recommended for password hashing.
Argon2 Parameters
Argon2 has several tunable parameters:
- Memory cost: how much memory is used (for example, 64 MB).
- Time cost: number of iterations (for example, 2 or 3).
- Parallelism: number of threads (for example, 1 or 2).
These control how slow and resource-intensive each hash is.
Example Argon2id hash string:
$argon2id$v=19$m=65536,t=3,p=4$CjW...$0uY...This includes:
- Algorithm (
argon2id). - Version (
v=19). - Parameters (
m=65536,t=3,p=4). - Salt.
- Hash.
Like with bcrypt, you usually store the full string in one password_hash column.
Argon2 Workflow Example
A Python-flavored example with argon2-cffi:
Registration
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # in KiB (64 MB)
parallelism=1
)
password = "Secret123!"
hash_str = ph.hash(password)
# Example: $argon2id$v=19$m=65536,t=3,p=1$...
Store hash_str in the database.
Login
from argon2.exceptions import VerifyMismatchError
stored_hash = row.password_hash
try:
ph.verify(stored_hash, "Secret123!")
is_correct = True
except VerifyMismatchError:
is_correct = FalseNo need to manage salts or parameters manually, the string has them all.
Storing and Verifying Passwords
Regardless of the specific algorithm, the general pattern is the same.
Registration Flow
- Receive password over HTTPS.
- Validate it according to your password policy (length, complexity, etc).
- Use a password hashing library to:
- Generate a random salt (internally).
- Hash the password with your chosen algorithm and cost.
- Store only the resulting hash string in your database.
- Discard the plain text password as soon as possible.
Example “users” table:
| Column | Type | Description |
|---|---|---|
| id | integer | user identifier |
| text | unique user email | |
| password_hash | text | bcrypt or Argon2 hash string |
| created_at | timestamp | when user was created |
| updated_at | timestamp | last update |
Login Flow
- Find user by identifier (usually email or username).
- If user not found, return login failure (do not reveal if email exists or not).
- Retrieve stored
password_hash. - Use the password hashing library to verify the candidate password.
- If verification succeeds, create a session or token.
- If it fails, return login failure.
Important implementation details:
- Use only well-tested library functions for hashing and verification.
- Do not implement hashing or comparison manually.
- Be careful not to include password values in logs or error messages.
Password Hash Parameters and Tuning
Choosing and managing cost parameters is a crucial part of secure password storage.
Measuring Cost
For your backend you can:
- Write a small script to hash a common test password 100 times.
- Measure the average time per hash.
- Adjust cost parameters until the time is acceptable.
Example decisions:
- For bcrypt, you might choose cost 12 if it takes around 100 ms per hash.
- For Argon2, you might choose:
time_cost = 3memory_cost = 64 * 1024(64 MB)parallelism = number_of_cpu_cores(or slightly lower)
Choose cost parameters that are slow enough to hurt attackers, but fast enough not to overload your servers during peak login or registration traffic.
Rehashing Strategy
Over time, as hardware gets faster or you change algorithms, you may want to upgrade existing password hashes.
Basic idea:
- When a user logs in successfully:
- Check whether their stored hash uses:
- A weaker algorithm, or
- Older, lower cost settings.
- If it does, immediately:
- Rehash the plain password with new settings.
- Store the new hash.
- Next time, only the new hash is used.
Many libraries provide helpers to check if a hash needs rehashing based on current desired parameters.
This lets you gradually upgrade your entire user base without forcing everyone to reset passwords at once.
Handling Password Resets Safely
Password reset flows are closely related to password storage because they give an attacker another potential path to set or obtain passwords.
Key rules:
- Never send passwords by email
- Do not send “Here is your password: Secret123!”.
- Do not send generated passwords directly.
- Only send one-time reset links or tokens.
- Use time-limited tokens
- Generate a secure random token (for example, 32 bytes).
- Store a hash of the token in your database, not the token itself.
- Send the raw token to the user in a URL.
- When they click the link, they send the token back, you verify by hashing and comparing to the stored hash.
- Set an expiration time, for example 30 minutes.
- After successful reset
- Let the user choose a new password.
- Hash it with your password hashing function.
- Delete or invalidate the reset token.
Example reset table:
| Column | Description |
|---|---|
| user_id | User to reset |
| token_hash | Hash of reset token |
| expires_at | Time after which token is invalid |
| used_at | Time token was used (or NULL if unused) |
This way, even if attackers read the reset token table, they cannot directly use it to reset passwords.
Protecting Password Hashes in the Database
Even strong hashing does not mean you should be careless with your database.
General Database Protection
- Use least privilege for database users.
- Avoid giving application roles broad admin rights.
- Keep regular backups but secure them with strong access controls.
- Monitor access and failed login attempts.
Avoid Accidental Leaks
- Never include
passwordorpassword_hashin logs. - Scrub sensitive fields in debugging tools and error pages.
- When returning user data in APIs, never expose
password_hashor other sensitive fields.
Example unsafe JSON response:
{
"id": 1,
"email": "alice@example.com",
"password_hash": "$2b$12$..."
}This must never happen.
Common Mistakes and How to Avoid Them
Here are some anti-patterns that show up often in real projects.
Mistake 1: Homegrown Password Hashing
Example:
import hashlib
def hash_password(password, salt):
return hashlib.sha256((salt + password).encode()).hexdigest()Issues:
- SHA256 is too fast.
- Easy to get details wrong (like not using unique random salts).
- No way to tune cost easily.
Better:
- Use
bcryptorargon2libraries with built-in salt and cost management.
Mistake 2: Single Global Salt
Example:
GLOBAL_SALT = "my_secret_salt"
hash = SHA256(GLOBAL_SALT + password)Issues:
- Every user has the same salt.
- If GLOBAL_SALT leaks, attackers can attack all passwords with precomputed tables.
- This does not prevent equal-password collisions.
Use per-user random salts instead, or rely on algorithms that handle this.
Mistake 3: Changing Algorithm Without Migration
Example scenario:
- Version 1 of your app uses bcrypt with cost 10.
- Version 2 switches to Argon2 directly.
- But existing user hashes are still bcrypt.
Solution:
- Keep both verification methods temporarily:
- Detect hash type by prefix (
$2b$for bcrypt,$argon2id$for Argon2). - Verify with the appropriate method.
- If bcrypt verification succeeds, rehash the plain password with Argon2 and update.
This incremental migration keeps backward compatibility and improves security over time.
Mistake 4: Overly Slow Settings Causing DoS
If you set cost too high, each login might take so long that your server cannot handle normal traffic, resulting in denial of service for your own users.
Mitigation:
- Benchmark performance on realistic hardware.
- Monitor login endpoint latency and error rates.
- Adjust cost gradually and carefully.
Putting It All Together: Example Design
Let us combine the ideas into a simple design you could use for a new backend.
Requirements
- Use Argon2id for password hashing.
- Target around 100 ms per hash on your production hardware.
- Provide a way to upgrade parameters over time.
Implementation Outline
- Configuration
# security_config.py
ARGON2_TIME_COST = 3
ARGON2_MEMORY_COST = 64 * 1024 # 64 MB
ARGON2_PARALLELISM = 1- Hashing service
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
time_cost=ARGON2_TIME_COST,
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
)
def hash_password(plain_password: str) -> str:
return ph.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
ph.verify(hashed_password, plain_password)
return True
except VerifyMismatchError:
return False
def needs_rehash(hashed_password: str) -> bool:
return ph.check_needs_rehash(hashed_password)- Registration handler
def register_user(email: str, password: str):
# Validate email & password policy here
password_hash = hash_password(password)
# Insert into DB: (email, password_hash, ...)- Login handler
def login_user(email: str, password: str):
user = get_user_by_email(email)
if not user:
return login_failed()
if not verify_password(password, user.password_hash):
return login_failed()
# Optional: upgrade hash if needed
if needs_rehash(user.password_hash):
new_hash = hash_password(password)
update_user_password_hash(user.id, new_hash)
return login_success(user)With this structure:
- Passwords are never stored or logged.
- Hashes are created and verified using a well tested library.
- Hash settings can evolve without forcing all users to reset passwords.
Summary
You have seen how proper password storage is central to backend security.
Key rules to remember:
- Never store plain text passwords or reversible encryption of passwords.
- Always use a dedicated password hashing function such as bcrypt or Argon2.
- Use unique, random salts per user, usually handled by the library.
- Tune cost parameters so hashing is slow enough for attackers but acceptable in normal use.
- Design password reset flows with tokens, not passwords, and avoid leaking any password data.
- Never expose
passwordorpassword_hashin logs or API responses.
Following these practices will not make your system perfectly secure, but it will ensure that a database breach does not immediately expose all your users' passwords. This is a fundamental building block for any serious backend system.
Views: 5
KAHIBARO