Skip to main content

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 MethodWhat Attacker Gets
Plaintext passwordsEvery password instantly. Users compromised everywhere they reused it.
Hashed passwordsUseless 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:

FieldWhy
hashed_passwordNamed explicitly—never confuse with plaintext
unique=TrueOne account per email
index=TrueFast lookup during login
UserCreateSeparate 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

PrincipleImplementation
Never store plaintexthash_password() before saving
Never return hashesResponse only includes id and email
Prevent enumerationDuplicate check before creation
Use modern algorithmsArgon2id (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.