Cryptographic Hashing - Password Storage Done Right
Before you read any further, study this code and predict the vulnerability:
import hashlib
def register_user(username: str, password: str, db):
password_hash = hashlib.md5(password.encode()).hexdigest()
db.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
(username, password_hash),
)
def verify_password(password: str, stored_hash: str) -> bool:
return hashlib.md5(password.encode()).hexdigest() == stored_hash
How many distinct vulnerabilities can you find? There are at least five. By the end of this lesson, you will understand every one of them and know how to eliminate each.
What You Will Learn
- The difference between data hashing and password hashing and why they require different algorithms
- Why MD5 and SHA-1 are fundamentally broken for password storage
- How bcrypt uses adaptive cost factors and built-in salting
- Why Argon2 won the Password Hashing Competition and when to use it
- The mechanics of salt and pepper in password storage
- How timing attacks leak information through string comparison
- How to use
hmac.compare_digestfor constant-time comparison - How to build a production-grade user registration flow
Prerequisites
- Python classes, bytes vs strings, encoding
- Basic understanding of hashing concepts (from Intermediate course)
- Familiarity with FastAPI and SQLAlchemy (from Intermediate course)
pip install bcrypt argon2-cffi passlib[bcrypt]
Part 1 - What Is a Cryptographic Hash Function?
A cryptographic hash function maps arbitrary-length input to a fixed-length output with five properties:
- Deterministic - same input always produces the same output
- Fast to compute - for data integrity, speed matters
- Pre-image resistant - given a hash, you cannot recover the input
- Second pre-image resistant - given an input, you cannot find a different input with the same hash
- Collision resistant - you cannot find any two inputs that produce the same hash
import hashlib
# SHA-256 produces a 256-bit (32-byte) digest
data = b"Engineers of AI - Python Advanced"
digest = hashlib.sha256(data).hexdigest()
print(digest)
# Output: a fixed 64-character hex string, e.g.
# "b3f7d1a8c2e9..."
# Same input always produces the same output
assert hashlib.sha256(data).hexdigest() == digest
# Change one bit and the output is completely different
data_modified = b"Engineers of AI - Python Advancee"
digest_modified = hashlib.sha256(data_modified).hexdigest()
assert digest != digest_modified
hashlib - Python's Standard Library
Python's hashlib module provides access to every hash algorithm your OpenSSL installation supports:
import hashlib
# Available algorithms
print(hashlib.algorithms_guaranteed)
# {'sha256', 'sha384', 'sha512', 'sha224', 'sha1', 'md5',
# 'blake2b', 'blake2s', 'sha3_256', 'sha3_384', 'sha3_512', ...}
# SHA-256 - the workhorse for data integrity
sha256_hash = hashlib.sha256(b"hello").hexdigest()
print(f"SHA-256: {sha256_hash}")
# SHA-256: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e7304...
# SHA-512 - larger digest, marginally slower
sha512_hash = hashlib.sha512(b"hello").hexdigest()
print(f"SHA-512: {sha512_hash}")
# SHA-512: 9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2df...
# Incremental hashing for large files
hasher = hashlib.sha256()
with open("large_file.bin", "rb") as f:
while chunk := f.read(8192):
hasher.update(chunk)
print(f"File hash: {hasher.hexdigest()}")
Use SHA-256 or SHA-512 for data integrity (file checksums, message authentication). Never use them alone for password storage.
Part 2 - Why MD5 and SHA-1 Are Broken for Passwords
MD5 and SHA-1 are not broken in the same way. Understanding the distinction matters:
MD5 - Collision Attacks
MD5 has known collision attacks since 2004. Two different inputs can produce the same hash. This breaks certificate signing, code signing, and any system relying on uniqueness.
import hashlib
import time
# MD5 is FAST - that is the problem for passwords
password = "SuperSecret123!"
start = time.perf_counter()
for _ in range(1_000_000):
hashlib.md5(password.encode()).hexdigest()
elapsed = time.perf_counter() - start
print(f"1M MD5 hashes: {elapsed:.2f}s")
# 1M MD5 hashes: ~0.35s (on modern hardware)
# That means ~3 million hashes/second on a single core
# A GPU can do BILLIONS per second
SHA-1 - Also Broken
Google demonstrated a practical SHA-1 collision in 2017 (SHAttered attack). SHA-1 should not be used for any security purpose.
The Real Problem: Speed
For password hashing, speed is the enemy. An attacker with a stolen database wants to try billions of candidate passwords. Fast hashes help the attacker:
import hashlib
import time
# Simulating a brute-force attack on a 6-character lowercase password
# 26^6 = 308,915,776 combinations
# At 3M hashes/second (single core MD5): ~103 seconds
# At 10B hashes/second (GPU cluster): ~0.03 seconds
# This is why you need SLOW hashes for passwords
candidates_per_second_md5 = 3_000_000
total_combinations = 26 ** 6
time_to_crack = total_combinations / candidates_per_second_md5
print(f"Time to crack 6-char lowercase with MD5: {time_to_crack:.0f}s")
# Time to crack 6-char lowercase with MD5: 103s
Never use MD5, SHA-1, SHA-256, or SHA-512 alone for password storage. They are too fast. An attacker with GPU hardware can test billions of candidates per second.
Rainbow Tables
A rainbow table is a precomputed lookup of hash-to-password mappings. Without a salt, every user with the password "password123" has the same hash. The attacker computes the hash once and matches it against every row:
import hashlib
# Without salt - identical passwords produce identical hashes
users = ["alice", "bob", "charlie"]
password = "password123"
for user in users:
h = hashlib.sha256(password.encode()).hexdigest()
print(f"{user}: {h}")
# alice: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
# bob: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
# charlie: ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
# All three are IDENTICAL - one lookup cracks all three
Part 3 - Salt and Pepper
Salt - Per-User Randomness
A salt is a random value concatenated with the password before hashing. Each user gets a unique salt, stored alongside the hash:
import hashlib
import os
def hash_with_salt(password: str) -> tuple[str, str]:
salt = os.urandom(32) # 256-bit random salt
salted = salt + password.encode()
digest = hashlib.sha256(salted).hexdigest()
return salt.hex(), digest
def verify_with_salt(password: str, salt_hex: str, stored_hash: str) -> bool:
salt = bytes.fromhex(salt_hex)
salted = salt + password.encode()
return hashlib.sha256(salted).hexdigest() == stored_hash
# Now identical passwords produce different hashes
salt1, hash1 = hash_with_salt("password123")
salt2, hash2 = hash_with_salt("password123")
print(f"Hash 1: {hash1}")
print(f"Hash 2: {hash2}")
print(f"Same? {hash1 == hash2}")
# Hash 1: a1b2c3...
# Hash 2: d4e5f6...
# Same? False
Salt defeats rainbow tables because the attacker must compute a separate table for every possible salt value - which is computationally infeasible.
Pepper - Application-Level Secret
A pepper is a secret value stored separately from the database (in environment variables or a secrets manager). Even if the database is compromised, the attacker cannot compute hashes without the pepper:
import hashlib
import os
import hmac
PEPPER = os.environ.get("PASSWORD_PEPPER", "").encode()
# In production: a 32-byte random value stored in a secrets manager
def hash_with_salt_and_pepper(password: str) -> tuple[str, str]:
salt = os.urandom(32)
# HMAC with pepper as key, salted password as message
digest = hmac.new(
PEPPER,
salt + password.encode(),
hashlib.sha256,
).hexdigest()
return salt.hex(), digest
Salt is stored in the database alongside the hash. Pepper is stored in the application configuration, never in the database. Salt prevents rainbow tables. Pepper prevents offline attacks even if the database is fully compromised.
Part 4 - bcrypt - The Industry Standard
bcrypt was designed specifically for password hashing. It has three critical properties:
- Adaptive cost factor - you can increase the work factor as hardware gets faster
- Built-in salt - 128-bit salt is generated and stored in the hash string
- Intentionally slow - Blowfish-based key setup is expensive by design
import bcrypt
import time
password = b"SuperSecret123!"
# Generate a salt and hash the password
# The cost factor (rounds) is log2 - 12 means 2^12 = 4096 iterations
start = time.perf_counter()
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
elapsed = time.perf_counter() - start
print(f"Hash: {hashed}")
# Hash: b'$2b$12$LJ3m4ys5Lz.QhPe0g3k9/.aBcDeFgHiJkLmNoPqRsTuVwXyZ012'
# ^^^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# algo cost 22-char salt 31-char hash
print(f"Time: {elapsed:.3f}s")
# Time: ~0.250s (at cost 12)
# Verification
assert bcrypt.checkpw(password, hashed)
assert not bcrypt.checkpw(b"WrongPassword", hashed)
Choosing the Cost Factor
The cost factor should make hashing take roughly 250ms on your production hardware. Increase it as hardware improves:
import bcrypt
import time
password = b"benchmark_password"
for rounds in range(10, 16):
start = time.perf_counter()
bcrypt.hashpw(password, bcrypt.gensalt(rounds=rounds))
elapsed = time.perf_counter() - start
print(f"Rounds={rounds}: {elapsed:.3f}s")
# Typical output on modern hardware:
# Rounds=10: 0.065s
# Rounds=11: 0.130s
# Rounds=12: 0.261s <-- good starting point
# Rounds=13: 0.522s
# Rounds=14: 1.044s
# Rounds=15: 2.089s
Start with cost factor 12 in 2024. Re-benchmark annually. If your server can handle 250ms per login request, use that cost factor. bcrypt is limited to 72 bytes of input \text{---} longer passwords are silently truncated.
Part 5 \text{---} Argon2 \text{---} The Password Hashing Competition Winner
Argon2 won the Password Hashing Competition (PHC) in 2015. It is memory-hard, meaning it requires a configurable amount of RAM to compute. This makes GPU and ASIC attacks dramatically more expensive.
from argon2 import PasswordHasher
import time
ph = PasswordHasher(
time_cost=3, # Number of iterations
memory_cost=65536, # 64 MB of RAM required
parallelism=4, # Number of parallel threads
hash_len=32, # Length of the hash output
salt_len=16, # Length of the random salt
)
password = "SuperSecret123!"
# Hash
start = time.perf_counter()
hashed = ph.hash(password)
elapsed = time.perf_counter() - start
print(f"Hash: {hashed}")
# Hash: $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$...
# ^^^^^^^^ ^^^^^^^^^^^^^^^^ ^^^^^^^^^^
# variant parameters salt+hash
print(f"Time: {elapsed:.3f}s")
# Time: ~0.300s
# Verify
assert ph.verify(hashed, password)
# Wrong password raises an exception
from argon2.exceptions import VerifyMismatchError
try:
ph.verify(hashed, "WrongPassword")
except VerifyMismatchError:
print("Password does not match")
# Password does not match
Argon2 Variants
| Variant | Use Case |
|---|---|
| Argon2d | Data-dependent memory access. Faster, but vulnerable to side-channel attacks. Use for cryptocurrency mining. |
| Argon2i | Data-independent memory access. Resistant to side-channel attacks. Use for password hashing in shared environments. |
| Argon2id | Hybrid. First pass is Argon2i, subsequent passes are Argon2d. Recommended default. |
from argon2 import PasswordHasher, Type
# Argon2id is the default and recommended variant
ph = PasswordHasher(type=Type.ID)
Argon2 vs bcrypt - When to Use Which
Never roll your own password hashing scheme. Even with salt and pepper, SHA-256 iterated manually is inferior to bcrypt/Argon2. These algorithms were designed by cryptographers and have survived years of cryptanalysis.
Part 6 - Timing Attacks and Constant-Time Comparison
The == operator in Python compares strings character by character and short-circuits on the first mismatch. An attacker can measure response time to determine how many leading characters of a hash match:
# VULNERABLE - timing side-channel
def verify_token_vulnerable(provided: str, stored: str) -> bool:
return provided == stored
# If the first character differs: fast return
# If the first 10 characters match: slower return
# The timing difference leaks information
The Fix: hmac.compare_digest
Python's hmac.compare_digest compares every byte regardless of where mismatches occur:
import hmac
# SECURE - constant-time comparison
def verify_token_secure(provided: str, stored: str) -> bool:
return hmac.compare_digest(provided.encode(), stored.encode())
# Always compares ALL bytes - no timing leak
import hmac
import time
token_stored = "a" * 64 # 64-character token
# Demonstrate timing difference with == (simplified)
def time_comparison(provided: str, stored: str, func, iterations=100_000):
start = time.perf_counter()
for _ in range(iterations):
func(provided, stored)
return time.perf_counter() - start
# With ==, early mismatch is faster than late mismatch
t_early = time_comparison(
"b" + "a" * 63, token_stored, lambda a, b: a == b
)
t_late = time_comparison(
"a" * 63 + "b", token_stored, lambda a, b: a == b
)
print(f"Early mismatch: {t_early:.4f}s")
print(f"Late mismatch: {t_late:.4f}s")
# Early mismatch: 0.0120s (faster - short-circuits early)
# Late mismatch: 0.0185s (slower - compares more characters)
# With hmac.compare_digest, timing is constant
t_early_safe = time_comparison(
"b" + "a" * 63, token_stored,
lambda a, b: hmac.compare_digest(a.encode(), b.encode())
)
t_late_safe = time_comparison(
"a" * 63 + "b", token_stored,
lambda a, b: hmac.compare_digest(a.encode(), b.encode())
)
print(f"Safe early mismatch: {t_early_safe:.4f}s")
print(f"Safe late mismatch: {t_late_safe:.4f}s")
# Safe early mismatch: 0.0250s
# Safe late mismatch: 0.0252s (effectively identical)
Use hmac.compare_digest any time you compare tokens, API keys, HMAC signatures, or session identifiers. The standard == operator is only safe for non-secret values.
Part 7 - passlib - Multi-Algorithm Support
In production systems, you may need to support multiple hashing algorithms during migration (e.g., migrating from bcrypt to Argon2). passlib handles this elegantly:
from passlib.context import CryptContext
# Define a context with preferred and deprecated algorithms
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
default="argon2",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
bcrypt__rounds=12,
deprecated=["bcrypt"], # bcrypt hashes will be auto-upgraded
)
# Hash a new password - uses Argon2 (the default)
hashed = pwd_context.hash("SuperSecret123!")
print(hashed[:30])
# $argon2id$v=19$m=65536,t=3,p=...
# Verify any supported hash
assert pwd_context.verify("SuperSecret123!", hashed)
# Check if the hash needs upgrading (e.g., old bcrypt hash)
import bcrypt
old_hash = bcrypt.hashpw(b"SuperSecret123!", bcrypt.gensalt(rounds=10)).decode()
print(f"Needs rehash? {pwd_context.needs_update(old_hash)}")
# Needs rehash? True (because bcrypt is deprecated in our context)
# Re-hash during login if needed
def login(username: str, password: str, db):
user = db.get_user(username)
if not pwd_context.verify(password, user.password_hash):
raise ValueError("Invalid credentials")
# Transparent upgrade to Argon2
if pwd_context.needs_update(user.password_hash):
user.password_hash = pwd_context.hash(password)
db.save_user(user)
return user
Part 8 \text{---} Real-World: Secure User Registration Flow
Putting it all together in a FastAPI application:
import re
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, field_validator
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession
app = FastAPI()
pwd_context = CryptContext(
schemes=["argon2"],
default="argon2",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
class RegisterRequest(BaseModel):
username: str
password: str
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
if len(v) < 12:
raise ValueError("Password must be at least 12 characters")
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain an uppercase letter")
if not re.search(r"[a-z]", v):
raise ValueError("Password must contain a lowercase letter")
if not re.search(r"\d", v):
raise ValueError("Password must contain a digit")
if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", v):
raise ValueError("Password must contain a special character")
return v
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/register", status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession):
# Check if username already exists
existing = await db.execute(
"SELECT id FROM users WHERE username = :u",
{"u": req.username},
)
if existing.scalar():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken",
)
# Hash password with Argon2
password_hash = pwd_context.hash(req.password)
await db.execute(
"INSERT INTO users (username, password_hash) VALUES (:u, :h)",
{"u": req.username, "h": password_hash},
)
await db.commit()
return {"message": "User registered successfully"}
@app.post("/login")
async def login(req: LoginRequest, db: AsyncSession):
result = await db.execute(
"SELECT id, password_hash FROM users WHERE username = :u",
{"u": req.username},
)
row = result.first()
if not row:
# Hash the password anyway to prevent timing-based user enumeration
pwd_context.hash(req.password)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
user_id, stored_hash = row
if not pwd_context.verify(req.password, stored_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
# Transparent hash upgrade
if pwd_context.needs_update(stored_hash):
new_hash = pwd_context.hash(req.password)
await db.execute(
"UPDATE users SET password_hash = :h WHERE id = :id",
{"h": new_hash, "id": user_id},
)
await db.commit()
# Return JWT token (covered in next lesson)
return {"access_token": "...", "token_type": "bearer"}
User enumeration prevention: Notice the login endpoint hashes the password even when the user does not exist. This ensures the response time is the same whether the username is valid or not, preventing attackers from discovering valid usernames through timing differences.
Part 9 - Returning to the Opening Puzzle
The original code had these vulnerabilities:
- MD5 for password hashing - too fast, collision-prone
- No salt - identical passwords produce identical hashes, vulnerable to rainbow tables
- No pepper - no defense if the database is compromised
- String comparison with
==- vulnerable to timing attacks (though less critical here since it compares hashes, not tokens) - No password strength validation - users can set "1" as their password
- No cost factor / work factor - the hash is instantaneous to compute
The fixed version uses Argon2id with built-in salt, passlib for algorithm management, Pydantic for password strength validation, and constant-time comparison under the hood.
Key Takeaways
- Data hashing (SHA-256, SHA-512) is for integrity checks - file verification, checksums, HMAC. It must be fast.
- Password hashing (bcrypt, Argon2) is for credential storage. It must be slow.
- MD5 and SHA-1 are broken for all security purposes. Do not use them.
- Salt (per-user random value) defeats rainbow tables. Store it alongside the hash.
- Pepper (application secret) adds a layer of defense if the database is compromised.
- Argon2id is the recommended algorithm for new applications. It is memory-hard, defeating GPU attacks.
- bcrypt remains a solid choice with decades of proven security. Use cost factor 12+.
- Use
hmac.compare_digestfor all secret comparisons to prevent timing attacks. - Use passlib to manage algorithm migration and transparent hash upgrades.
- Always perform a dummy hash on failed lookups to prevent user enumeration timing attacks.
Graded Practice Challenges
Level 1 - Identify the Vulnerability
Question 1: What is wrong with this code?
import hashlib
def verify_api_key(provided_key: str, stored_key: str) -> bool:
return hashlib.sha256(provided_key.encode()).hexdigest() == stored_key
Answer
Two issues: (1) The == comparison is vulnerable to timing attacks. An attacker can measure response time differences to guess the hash character by character. Use hmac.compare_digest instead. (2) If stored_key is a hash of the API key, the hashing is fine for data integrity, but the comparison must be constant-time.
import hashlib
import hmac
def verify_api_key(provided_key: str, stored_key: str) -> bool:
computed = hashlib.sha256(provided_key.encode()).hexdigest()
return hmac.compare_digest(computed, stored_key)
Question 2: A developer stores passwords as sha256(password + username). Why is this insufficient even though the username acts as a "salt"?
Answer
Three problems: (1) Usernames are not random - they are predictable and often short, making them weak salts. (2) SHA-256 is too fast for password hashing; GPU attacks can try billions of candidates per second. (3) If a user changes their username, the hash becomes invalid and the password must be re-hashed, creating operational fragility. Use bcrypt or Argon2 with a cryptographically random salt.
Question 3: What happens when you use bcrypt with a 100-character password?
Answer
bcrypt silently truncates input to 72 bytes. Characters beyond position 72 are ignored. This means bcrypt.hashpw(b"a" * 72 + b"X", salt) and bcrypt.hashpw(b"a" * 72 + b"Y", salt) produce the same hash. For applications where long passwords matter, use Argon2 (no length limit) or pre-hash with SHA-256 before passing to bcrypt: bcrypt.hashpw(hashlib.sha256(password).digest(), salt).
Level 2 - Fix the Vulnerability
The following registration system has multiple security flaws. Fix all of them:
import hashlib
import sqlite3
db = sqlite3.connect("users.db")
def register(username, password):
h = hashlib.sha1(password.encode()).hexdigest()
db.execute("INSERT INTO users VALUES (?, ?)", (username, h))
db.commit()
def login(username, password):
row = db.execute(
"SELECT password_hash FROM users WHERE username=?", (username,)
).fetchone()
if row and row[0] == hashlib.sha1(password.encode()).hexdigest():
return True
return False
Solution
import hmac
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["argon2"], default="argon2")
def register(username: str, password: str):
if len(password) < 12:
raise ValueError("Password too short")
hashed = pwd_context.hash(password)
db.execute("INSERT INTO users VALUES (?, ?)", (username, hashed))
db.commit()
def login(username: str, password: str) -> bool:
row = db.execute(
"SELECT password_hash FROM users WHERE username=?", (username,)
).fetchone()
if not row:
# Prevent user enumeration via timing
pwd_context.hash(password)
return False
return pwd_context.verify(password, row[0])
Changes: (1) Replaced SHA-1 with Argon2id via passlib. (2) Added password strength check. (3) Added dummy hash on missing user to prevent timing-based enumeration. (4) passlib.verify uses constant-time comparison internally.
Level 3 - Design a Secure System
Design a password storage system for a multi-tenant SaaS application where:
- Each tenant can configure their own password policy (minimum length, complexity rules)
- The system must support migrating from bcrypt (legacy) to Argon2 without downtime
- Passwords must survive a full database breach (attacker gets all rows)
- The system must handle 1000 concurrent login requests per second
Document your design decisions including: algorithm choice, cost factor tuning, salt/pepper strategy, hash upgrade strategy, and how you would handle the performance requirement at scale.
Design Hints
Key design decisions:
- Algorithm: Argon2id as default, bcrypt as deprecated fallback via passlib CryptContext
- Cost tuning: Benchmark Argon2 on production hardware. Target 250-300ms per hash. Use
memory_cost=65536andtime_cost=2-3. - Salt: Built into Argon2 (16 bytes random). No manual salt management needed.
- Pepper: Store in HashiCorp Vault or AWS Secrets Manager. Apply as HMAC key before passing to Argon2. Rotate pepper by re-hashing on next login.
- Hash upgrade: Store algorithm version in the hash string (Argon2/bcrypt do this natively). On login, call
pwd_context.needs_update()and re-hash if needed. - Performance: At 300ms per hash, a single core handles ~3 logins/second. For 1000/s, you need a worker pool. Use async with
loop.run_in_executorto offload hashing to a thread pool of ~300+ threads, or distribute across multiple API instances behind a load balancer. - Tenant policies: Store policy rules in a tenant config table. Validate via Pydantic model with dynamic field_validators loaded from config.
What's Next
In the next lesson, JWT Authentication, you will learn how to issue and validate stateless JSON Web Tokens, implement refresh token rotation, and avoid the most common JWT security mistakes - building directly on the password hashing foundation established here.
