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 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:
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.
Auth flow diagram
Step-by-step implementation
1) Standardize on one hashing library
For new Python apps, use Argon2id through a maintained library.
Install:
pip install pwdlibPin dependency versions:
pwdlib==0.2.12) Create a single auth utility module
Do not spread hashing logic across routes, serializers, model hooks, and services.
# 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:
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.
def authenticate_user(user, candidate_password: str) -> bool:
return verify_password(candidate_password, user.password_hash)FastAPI-style login path:
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 True6) 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:
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:
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:
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_atStore only token_hash, not the raw token.
Reset completion should:
- verify token
- hash new password
- update
password_changed_at - delete reset token record
- revoke active sessions
See also:
11) Support legacy hashes during migration
Detect legacy formats before attempting the new verifier.
Example strategy:
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_atif 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:
# 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/changeCommon 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:
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:
python -c "import importlib.metadata as m; print(m.version('pwdlib'))"Check hash prefix format:
python -c "h='$argon2id$...'; print(h.startswith('$argon2id$'))"Inspect database field lengths:
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:
grep -R "hash_password\|verify_password\|password_hash" -n .Search for dangerous logging:
grep -R "print(request\|password\|reset_token\|Authorization" -n .Inspect installed hash-related packages:
pip freeze | grep -E "argon|bcrypt|passlib|pwdlib"Benchmark verification cost on production-like hardware:
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.
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:
- Implement User Authentication (Login/Register)
- Session Management vs JWT (When to Use What)
- SaaS Production Checklist
Related guides
- Implement User Authentication (Login/Register)
- Password Reset Flow Implementation
- Session Management vs JWT (When to Use What)
- Common Auth Bugs and Fixes
- SaaS Production Checklist
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.