User Management & Password Hashing
Your API has tasks. Now it needs users. Before anyone can log in, they need an account. And before you store passwords, you need to understand why you never store passwords.
You store hashes.
Why Password Hashing Matters
If your database leaks (and breaches happen), what does the attacker get?
| Storage Method | What Attacker Gets |
|---|---|
| Plaintext passwords | Every password instantly. Users compromised everywhere they reused it. |
| Hashed passwords | Useless strings. Each hash must be cracked individually—expensive and slow. |
Argon2 is the current gold standard for password hashing:
- Memory-hard (expensive to parallelize on GPUs)
- Winner of the Password Hashing Competition (2015)
- Configurable difficulty (can increase over time)
This lesson implements user signup with proper password hashing. The next lesson adds JWT authentication for login.
Installing Dependencies
uv add pwdlib[argon2]
pwdlib- Modern password hashing library[argon2]- Argon2 algorithm support
Password Hashing Functions
Create security.py:
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
password_hash = PasswordHash((Argon2Hasher(),))
def hash_password(password: str) -> str:
"""Hash a password with Argon2."""
return password_hash.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return password_hash.verify(plain_password, hashed_password)
Test it in Python:
>>> from security import hash_password, verify_password
>>> hashed = hash_password("mysecret")
>>> hashed
'$argon2id$v=19$m=65536,t=3,p=4$randomsalt$longhashstring'
>>> verify_password("mysecret", hashed)
True
>>> verify_password("wrongpassword", hashed)
False
Notice the hash includes algorithm parameters (m=65536,t=3,p=4). This means you can upgrade security settings over time without breaking existing hashes.
User Model
Add to models.py:
from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import datetime
class User(SQLModel, table=True):
"""User account with hashed password."""
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(unique=True, index=True)
hashed_password: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class UserCreate(SQLModel):
"""Request model for user signup."""
email: str
password: str
Key design decisions:
| Field | Why |
|---|---|
hashed_password | Named explicitly—never confuse with plaintext |
unique=True | One account per email |
index=True | Fast lookup during login |
UserCreate | Separate model for requests (has password, not hashed_password) |
Signup Endpoint
Add to main.py:
from fastapi import FastAPI, Depends, HTTPException, status
from sqlmodel import Session, select
from models import User, UserCreate
from security import hash_password
from database import get_session, create_db_and_tables
app = FastAPI(title="Task API")
@app.on_event("startup")
def on_startup():
create_db_and_tables()
@app.post("/users/signup", status_code=201)
def signup(
user_data: UserCreate,
session: Session = Depends(get_session)
):
"""Create a new user account."""
# Check if email already exists
existing = session.exec(
select(User).where(User.email == user_data.email)
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create user with hashed password
user = User(
email=user_data.email,
hashed_password=hash_password(user_data.password)
)
session.add(user)
session.commit()
session.refresh(user)
# Return safe fields only
return {"id": user.id, "email": user.email}
Test the endpoint:
curl -X POST http://localhost:8000/users/signup \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "SecurePass123"}'
Output:
{"id": 1, "email": "alice@example.com"}
Try the same email again:
curl -X POST http://localhost:8000/users/signup \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "DifferentPass"}'
Output:
{"detail": "Email already registered"}
Security Principles Applied
| Principle | Implementation |
|---|---|
| Never store plaintext | hash_password() before saving |
| Never return hashes | Response only includes id and email |
| Prevent enumeration | Duplicate check before creation |
| Use modern algorithms | Argon2id (memory-hard, GPU-resistant) |
Hands-On Exercise
Step 1: Install pwdlib:
uv add pwdlib[argon2]
Step 2: Create security.py with hash/verify functions
Step 3: Add User and UserCreate models to models.py
Step 4: Add signup endpoint to main.py
Step 5: Test the flow:
# Create a user
curl -X POST http://localhost:8000/users/signup \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "MySecure123"}'
# Verify duplicate prevention
curl -X POST http://localhost:8000/users/signup \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "Different"}'
Step 6: Check the database—verify passwords are hashed, not plaintext
Common Mistakes
Mistake 1: Storing plaintext passwords
# NEVER do this
user = User(email=email, password=password)
# ALWAYS hash
user = User(email=email, hashed_password=hash_password(password))
Mistake 2: Returning the hash in responses
# Wrong - exposes hash
return user
# Correct - only safe fields
return {"id": user.id, "email": user.email}
Mistake 3: Using weak hashing algorithms
# Wrong - MD5 and SHA are not password hashing algorithms
import hashlib
hashed = hashlib.md5(password.encode()).hexdigest()
# Correct - use Argon2
from security import hash_password
hashed = hash_password(password)
Mistake 4: Naming the field password instead of hashed_password
# Misleading - suggests it might be plaintext
class User(SQLModel, table=True):
password: str
# Clear - obviously a hash
class User(SQLModel, table=True):
hashed_password: str
What's Next?
You have users. They can sign up. But they can't do anything yet—no login, no sessions, no protected routes.
The next lesson adds JWT authentication:
- Login endpoint that verifies passwords
- Token generation for authenticated sessions
- Protected routes that require tokens
Try With AI
Prompt 1: Password Strength Validation
I want to enforce password requirements before hashing:
- Minimum 8 characters
- At least one uppercase, one lowercase, one number
Should I validate in the Pydantic model or security.py?
Show me both approaches with tradeoffs.
What you're learning: Input validation location matters. Pydantic validates at API boundary (user-friendly errors); security module validates at hashing time (defense in depth).
Prompt 2: Email Validation
How do I validate that emails are properly formatted before signup?
I want to reject "not-an-email" but accept "user@example.com".
Show me Pydantic EmailStr and explain when it's enough
vs when you need external validation.
What you're learning: Pydantic's EmailStr validates format. Real email verification requires sending a confirmation link—different problem.
Prompt 3: Password Reset Flow
A user forgot their password. Walk me through the secure
password reset flow:
1. What endpoint do they call?
2. How do I generate a reset token?
3. How long should it be valid?
4. What happens when they use it?
What you're learning: Password reset is a common security-critical feature. Understanding the token-based flow prepares you for production systems.
Reflect on Your Skill
You built a fastapi-agent skill in Lesson 0. Test and improve it based on what you learned.
Test Your Skill
Using my fastapi-agent skill, help me implement user signup with password hashing.
Does my skill include Argon2 hashing with pwdlib and proper User model design?
Identify Gaps
Ask yourself:
- Did my skill include password hashing with pwdlib and Argon2Hasher?
- Did it create separate User (database) and UserCreate (request) models?
- Did it check for duplicate emails before creating users?
Improve Your Skill
If you found gaps:
My fastapi-agent skill is missing user management patterns.
Update it to include password hashing with pwdlib/Argon2,
User model with hashed_password field, and signup endpoint
with duplicate email prevention.