Password Hashing and Security Best Practices

The essential playbook for implementing password hashing and security best practices in your SaaS.

Use a memory-hard password hashing algorithm, never store plaintext or encrypted passwords, and make verification, reset, and migration paths explicit.

This page focuses on secure password storage for Flask and FastAPI-style SaaS apps with copy-paste-friendly implementation steps and production checks.

Quick Fix / Quick Setup

Default to Argon2id.

python
# Python example using pwdlib (Argon2id)
# pip install pwdlib
from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()

# hash before storing
stored_hash = password_hash.hash("user-plain-password")

# verify on login
ok = password_hash.verify("user-plain-password", stored_hash)

# upgrade hash when params change
if ok and password_hash.needs_rehash(stored_hash):
    stored_hash = password_hash.hash("user-plain-password")
    # save new hash to DB

# FastAPI-style helpers

def hash_password(password: str) -> str:
    return password_hash.hash(password)


def verify_password(password: str, hashed: str) -> bool:
    return password_hash.verify(password, hashed)

Notes:

  • Keep one hash string per user in the database.
  • Do not store plaintext passwords.
  • Do not encrypt passwords as your primary storage method.
  • If you already use bcrypt safely, keep it temporarily and migrate with rehash-on-login.

Example schema:

sql
ALTER TABLE users
ADD COLUMN password_hash TEXT NOT NULL,
ADD COLUMN password_changed_at TIMESTAMP NULL;

What’s happening

  • Password hashing is a one-way transformation for credential storage.
  • Encryption is reversible and is not the right default for password storage.
  • Modern password hashing formats include salt and work parameters inside the stored hash string.
  • Argon2id is the practical default for new apps because it is memory-hard and more resistant to GPU cracking than legacy choices.
  • Verification works by hashing the candidate password using the parameters embedded in the stored hash.
  • Rehash-on-login lets you upgrade algorithm parameters without forcing all users through a reset flow.
register
hash
store
login
verify
needs_rehash
update hash

Auth flow diagram

Step-by-step implementation

1) Standardize on one hashing library

For new Python apps, use Argon2id through a maintained library.

Install:

bash
pip install pwdlib

Pin dependency versions:

txt
pwdlib==0.2.1

2) Create a single auth utility module

Do not spread hashing logic across routes, serializers, model hooks, and services.

python
# app/security/passwords.py
from pwdlib import PasswordHash

_password_hash = PasswordHash.recommended()

def hash_password(password: str) -> str:
    return _password_hash.hash(password)

def verify_password(password: str, stored_hash: str) -> bool:
    return _password_hash.verify(password, stored_hash)

def needs_rehash(stored_hash: str) -> bool:
    return _password_hash.needs_rehash(stored_hash)

3) Hash only at the correct entry points

Hash passwords only during:

  • registration
  • password change
  • admin-set-password flows
  • reset completion

Do not hash:

  • when reading from the database
  • inside generic save hooks unless you control all call paths
  • twice in both route and model logic

4) Store the full hash string in one column

Use one column such as users.password_hash.

Postgres example:

sql
CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  password_changed_at TIMESTAMP NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

Do not split salt into a separate column unless your library specifically requires it.

5) Verify on login with library helpers

Fetch the user first, then verify the candidate password against the stored hash.

python
def authenticate_user(user, candidate_password: str) -> bool:
    return verify_password(candidate_password, user.password_hash)

FastAPI-style login path:

python
from app.security.passwords import verify_password, hash_password, needs_rehash

def login_user(user, candidate_password: str, db):
    if not verify_password(candidate_password, user.password_hash):
        return False

    if needs_rehash(user.password_hash):
        user.password_hash = hash_password(candidate_password)
        db.add(user)
        db.commit()

    return True

6) Implement rehash-on-login

If a password is valid and your library indicates the hash is outdated, issue a new hash immediately.

This is the safest migration path for:

  • higher Argon2id cost settings
  • bcrypt to Argon2id migration
  • old parameter cleanup

7) Add rate limits to auth endpoints

Protect:

  • login
  • forgot password
  • reset completion
  • account recovery

Example with slowapi in FastAPI:

python
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

# example: 5 login attempts per minute per IP
@limiter.limit("5/minute")
async def login(request):
    ...

If you are behind a proxy, make sure forwarded IP handling is correct before relying on IP-based limits.

8) Set practical password policy rules

Use length-first rules.

Recommended minimum:

  • at least 12 characters for user passwords
  • allow password managers and long passphrases

Reject known weak passwords if possible.

9) Never log sensitive values

Do not log:

  • plaintext passwords
  • password hashes
  • reset tokens
  • raw request bodies containing credentials
  • authorization headers

Example redaction pattern:

python
SENSITIVE_KEYS = {"password", "password_hash", "reset_token", "authorization"}

def redact(data: dict) -> dict:
    return {k: ("[REDACTED]" if k.lower() in SENSITIVE_KEYS else v) for k, v in data.items()}

10) Implement password reset safely

Use:

  • short-lived reset tokens
  • single-use semantics
  • hashed tokens if stored server-side
  • forced session invalidation after reset

Server-side hashed reset token example:

python
import hashlib
import secrets
from datetime import datetime, timedelta

def generate_reset_token():
    raw = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(raw.encode()).hexdigest()
    expires_at = datetime.utcnow() + timedelta(minutes=30)
    return raw, token_hash, expires_at

Store only token_hash, not the raw token.

Reset completion should:

  1. verify token
  2. hash new password
  3. update password_changed_at
  4. delete reset token record
  5. revoke active sessions

See also:

11) Support legacy hashes during migration

Detect legacy formats before attempting the new verifier.

Example strategy:

python
def detect_hash_scheme(stored_hash: str) -> str:
    if stored_hash.startswith("$argon2id$"):
        return "argon2id"
    if stored_hash.startswith("$2a$") or stored_hash.startswith("$2b$") or stored_hash.startswith("$2y$"):
        return "bcrypt"
    if stored_hash.startswith("pbkdf2:"):
        return "pbkdf2"
    return "unknown"

On successful legacy verification:

  • replace old hash with Argon2id
  • update password_changed_at if needed
  • keep migration logic covered by tests

12) Add test coverage for all auth paths

Cover:

  • registration stores hash, not plaintext
  • login succeeds with valid credentials
  • login fails with wrong password
  • rehash-on-login updates stale hashes
  • password reset replaces stored hash
  • session invalidation occurs after reset
  • legacy verification migrates correctly

13) Document your password policy

Put the target algorithm and expectations in the repo:

md
# Password storage policy

- Algorithm: Argon2id
- Library: pwdlib
- Storage: full hash string in users.password_hash
- Rehash: performed on successful login when needed
- Reset tokens: single-use, short-lived, hashed at rest
- Session invalidation: required after password reset/change

Common causes

  • Using a fast general-purpose hash like SHA-256 instead of a password hashing algorithm.
  • Double-hashing passwords during registration or updates.
  • Comparing plaintext to hashed values manually instead of using the library verifier.
  • Mixing bcrypt, PBKDF2, and Argon2 hashes without legacy detection logic.
  • Low work-factor settings copied from old tutorials.
  • Hash library missing or different across local, CI, and production environments.
  • Passwords or reset tokens leaking into logs.
  • Password reset flow updates the hash but does not invalidate old sessions.
  • Using different Unicode normalization behavior between services handling registration and login.
  • Database column too short and truncating the stored hash.

Debugging tips

Confirm basic hashing and verification:

bash
python -c "from pwdlib import PasswordHash; ph=PasswordHash.recommended(); h=ph.hash('test123456'); print(h); print(ph.verify('test123456', h)); print(ph.needs_rehash(h))"

Check installed library version:

bash
python -c "import importlib.metadata as m; print(m.version('pwdlib'))"

Check hash prefix format:

bash
python -c "h='$argon2id$...'; print(h.startswith('$argon2id$'))"

Inspect database field lengths:

bash
python -c "import sqlite3; con=sqlite3.connect('app.db'); cur=con.cursor(); cur.execute('select id, length(password_hash) from users limit 5'); print(cur.fetchall())"

Search the codebase for duplicated hashing logic:

bash
grep -R "hash_password\|verify_password\|password_hash" -n .

Search for dangerous logging:

bash
grep -R "print(request\|password\|reset_token\|Authorization" -n .

Inspect installed hash-related packages:

bash
pip freeze | grep -E "argon|bcrypt|passlib|pwdlib"

Benchmark verification cost on production-like hardware:

bash
python -m timeit -s "from pwdlib import PasswordHash; ph=PasswordHash.recommended(); h=ph.hash('test123456')" "ph.verify('test123456', h)"

Extra checks:

  • If all logins fail after deploy, verify the same library and runtime version exist in every environment.
  • If users are being logged out, separate session secret rotation issues from password verification issues.
  • If some users can log in and others cannot, inspect legacy hash format detection first.
  • If verification breaks only for certain characters, inspect Unicode normalization across services.

Decision tree for hash format detection and rehash path.

What is the failure type?
Hash format detection
Diagnose: hash format detection
Rehash path
Diagnose: rehash path

Checklist

  • Argon2id selected for new passwords.
  • Single helper module used for hashing and verification.
  • No plaintext passwords or reset tokens stored.
  • Full hash string stored in one column.
  • Rehash-on-login implemented.
  • Login and reset endpoints rate limited.
  • Password reset invalidates active sessions.
  • Sensitive auth data excluded from logs.
  • HTTPS enforced in production.
  • Tests cover register, login, reset, and migration paths.

Cross-check with:

Related guides

FAQ

What algorithm should I choose for a new SaaS?

Argon2id is the practical default for new apps. It is memory-hard and better aligned with current password storage guidance than legacy fast hashes.

Is bcrypt still acceptable?

Yes, if already deployed with a strong cost factor and stable library support, but plan a migration path to Argon2id using rehash-on-login.

Should I add a pepper?

Only if you can manage it operationally as a secret outside the database. A pepper can help, but poor secret handling can create new failure modes.

How do I migrate old password hashes?

Detect the legacy hash format at login, verify with the old algorithm, then immediately replace it with a new Argon2id hash after successful authentication.

Do password changes need session invalidation?

Yes. Rotate or revoke existing sessions after password changes and reset completion to reduce account takeover persistence.

Final takeaway

For a small SaaS, the secure default is simple:

  • use Argon2id
  • centralize hashing logic
  • implement rehash-on-login
  • rate limit auth endpoints
  • invalidate sessions after password changes or resets

Most production failures come from weak algorithm choices, double-hashing, inconsistent runtime environments, truncated hash columns, or unsafe reset flows.