KAHIBARO
Discord Login Register

15.11 Secure Password Storage

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:

idemailpassword
1alice@example.comsecret123
2bob@example.comp@ssw0rd

This is the worst possible approach.

Problems:

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:

idemailenc_password
1alice@example.comAES_ENC('secret123', 'mysecret')

Drawbacks:

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:

text
hash = SHA256("secret123")

and store:

idemailpassword_hash
1alice@example.comef92b778... (SHA256 of secret123)

During login, you compute hash(candidate_password) and compare.

This is better than plain text, but still not good enough:

  1. 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.

  1. 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.

  1. 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

You never decrypt a hash. You only compute the hash of the candidate password and check if it matches.

Encryption

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:

text
hash1 = SHA256("secret123")
hash2 = SHA256("secret123")
# hash1 == hash2

With salt:

text
salt1 = random_bytes()
salt2 = random_bytes()
hash1 = SHA256(salt1 + "secret123")
hash2 = SHA256(salt2 + "secret123")
# hash1 != hash2

Benefits:

  1. Same passwords have different hashes

Attackers cannot see which users share passwords.

  1. 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.

  1. Per-user protection

Even if an attacker cracks one password, they do not automatically obtain other users’ passwords.

Salt Generation and Storage

Typical pattern:

Example table:

idemailsaltpassword_hash
1alice@example.come0f3e5c2... (16 or 32 random bytes)8a1b24c5... (hash of salt + password)

At login:

  1. Retrieve salt and password_hash for the user.
  2. Compute hash(salt + candidate_password).
  3. 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:

These are called password hashing functions or key derivation functions.

Common choices:

AlgorithmTypeStatus
bcryptPassword hashWidely used and battle tested
scryptPassword hashMemory hard, less common now
PBKDF2KDFUsed in many standards
Argon2Password hashModern, 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

Example bcrypt hash:

text
$2b$12$QeWZWbCwUGIFDNb4L3IFnOm24eG6PzwSUIvwrJqTkOUoUTr0MN/BK

Breakdown:

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

  1. User sends:
json
   {
     "email": "alice@example.com",
     "password": "Secret123!"
   }
  1. Backend:
python
   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")
  1. Store only hash_str in 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

  1. User sends:
json
   {
     "email": "alice@example.com",
     "password": "Secret123!"
   }
  1. Backend:
python
   stored_hash = row.password_hash.encode("utf-8")
   candidate = "Secret123!".encode("utf-8")
   is_correct = bcrypt.checkpw(candidate, stored_hash)
  1. If is_correct is True, authentication can proceed.

Note:

Cost Factor

The cost factor controls how slow the hashing is. For bcrypt, the work is roughly proportional to $2^{cost}$.

You need to balance security and performance:

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:

Argon2 Parameters

Argon2 has several tunable parameters:

These control how slow and resource-intensive each hash is.

Example Argon2id hash string:

text
$argon2id$v=19$m=65536,t=3,p=4$CjW...$0uY...

This includes:

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

python
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

python
from argon2.exceptions import VerifyMismatchError
stored_hash = row.password_hash
try:
    ph.verify(stored_hash, "Secret123!")
    is_correct = True
except VerifyMismatchError:
    is_correct = False

No 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

  1. Receive password over HTTPS.
  2. Validate it according to your password policy (length, complexity, etc).
  3. Use a password hashing library to:
    • Generate a random salt (internally).
    • Hash the password with your chosen algorithm and cost.
  4. Store only the resulting hash string in your database.
  5. Discard the plain text password as soon as possible.

Example “users” table:

ColumnTypeDescription
idintegeruser identifier
emailtextunique user email
password_hashtextbcrypt or Argon2 hash string
created_attimestampwhen user was created
updated_attimestamplast update

Login Flow

  1. Find user by identifier (usually email or username).
  2. If user not found, return login failure (do not reveal if email exists or not).
  3. Retrieve stored password_hash.
  4. Use the password hashing library to verify the candidate password.
  5. If verification succeeds, create a session or token.
  6. If it fails, return login failure.

Important implementation details:

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:

  1. Write a small script to hash a common test password 100 times.
  2. Measure the average time per hash.
  3. Adjust cost parameters until the time is acceptable.

Example decisions:

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:

  1. When a user logs in successfully:
    • Check whether their stored hash uses:
      • A weaker algorithm, or
      • Older, lower cost settings.
  2. If it does, immediately:
    • Rehash the plain password with new settings.
    • Store the new hash.
  3. 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:

  1. 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.
  2. 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.
  3. 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:

ColumnDescription
user_idUser to reset
token_hashHash of reset token
expires_atTime after which token is invalid
used_atTime 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

Avoid Accidental Leaks

Example unsafe JSON response:

json
{
  "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:

python
import hashlib
def hash_password(password, salt):
    return hashlib.sha256((salt + password).encode()).hexdigest()

Issues:

Better:

Mistake 2: Single Global Salt

Example:

python
GLOBAL_SALT = "my_secret_salt"
hash = SHA256(GLOBAL_SALT + password)

Issues:

Use per-user random salts instead, or rely on algorithms that handle this.

Mistake 3: Changing Algorithm Without Migration

Example scenario:

Solution:

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:

Putting It All Together: Example Design

Let us combine the ideas into a simple design you could use for a new backend.

Requirements

Implementation Outline

  1. Configuration
python
   # security_config.py
   ARGON2_TIME_COST = 3
   ARGON2_MEMORY_COST = 64 * 1024  # 64 MB
   ARGON2_PARALLELISM = 1
  1. Hashing service
python
   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)
  1. Registration handler
python
   def register_user(email: str, password: str):
       # Validate email & password policy here
       password_hash = hash_password(password)
       # Insert into DB: (email, password_hash, ...)
  1. Login handler
python
   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:

Summary

You have seen how proper password storage is central to backend security.

Key rules to remember:

  1. Never store plain text passwords or reversible encryption of passwords.
  2. Always use a dedicated password hashing function such as bcrypt or Argon2.
  3. Use unique, random salts per user, usually handled by the library.
  4. Tune cost parameters so hashing is slow enough for attackers but acceptable in normal use.
  5. Design password reset flows with tokens, not passwords, and avoid leaking any password data.
  6. Never expose password or password_hash in 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

Comments

Please login to add a comment.

Don't have an account? Register now!