Let’s Start With a Real Problem
A user forgets their password. It happens constantly — it’s one of the single most common support requests any real application gets. Right now, your API has no way to handle this at all: the only path back into their account is registering a brand-new one, abandoning everything tied to their original account. Worse, if you build this feature carelessly, you can accidentally create serious security holes — leaking whether an email is registered, issuing reset tokens that never expire, or making your API’s response hang for several seconds while it waits for an email to actually send.
This final chapter builds a complete, secure “forgot password” flow — and along the way, introduces background tasks, which let your API respond instantly to the client while slower work (like sending an email) continues quietly after the response has already gone out.
What You’ll Learn
- How to build a complete forgot-password → reset-password flow
- How to use FastAPI’s
BackgroundTasksso slow operations don’t block the response - Why password reset tokens need different handling than login access tokens
- How to build one-time-use, revocable reset tokens stored in the database — a meaningfully more secure pattern than a simple JWT
- Theory + hands-on assignment questions to lock in what you’ve learned
Theory: Why BackgroundTasks Matter Here
Sending an email typically means talking to an external mail service over the network — something that can easily take a second or more. Without background tasks, your POST /auth/forgot-password endpoint would have to await that entire email-sending process before it could respond, leaving the client’s request hanging the whole time.
FastAPI’s BackgroundTasks solves this elegantly: you hand it a function and its arguments, FastAPI returns your response to the client immediately, and only after the response has been sent does it actually run your background function.
@router.post("/forgot-password")
def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks):
background_tasks.add_task(send_email, data.email, "some content")
return {"message": "If that email exists, a reset link has been sent."}
⚠️ A security detail worth internalizing immediately: notice the response message above never confirms whether the email actually exists in your system. This is deliberate — and identical in spirit to the anti-enumeration pattern from Chapter 14’s login endpoint. If your API responded differently for “email found” versus “email not found,” an attacker could use your forgot-password endpoint to silently discover which email addresses have accounts on your platform.
Program 1: A Basic Forgot Password / Reset Password Flow
Program Objective
Build a small, standalone FastAPI project with a POST /auth/forgot-password endpoint that generates a short-lived JWT reset token and “sends” it by email using BackgroundTasks, plus a POST /auth/reset-password endpoint that verifies that token and updates the user’s password.
Step-by-Step Implementation
Step 1: Create the project
uv init password-reset-basics-demo
cd password-reset-basics-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
password-reset-basics-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── security.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── password_reset.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ └── email_service.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ └── auth.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Password Reset Basics Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
jwt_secret_key: str = "a-very-insecure-demo-secret-change-this"
jwt_algorithm: str = "HS256"
password_reset_token_expire_minutes: int = 15
settings = Settings()
app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_password_reset_token(email: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.password_reset_token_expire_minutes)
to_encode = {"sub": email, "type": "password_reset", "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise ValueError("Invalid or expired token.") from exc
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
model_config = ConfigDict(from_attributes=True)
app/schemas/password_reset.py
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: Annotated[str, Field(min_length=8, max_length=64)]
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()
app/services/email_service.py
import time
def send_password_reset_email(email: str, token: str) -> None:
"""
Simulates sending an email. In a real project, this would call a service
like SendGrid, Mailgun, Amazon SES, or an SMTP server via `fastapi-mail`.
"""
time.sleep(2) # simulate real network latency talking to an email provider
reset_link = f"https://example.com/reset-password?token={token}"
print("\n--- Simulated Email ---")
print(f"To: {email}")
print("Subject: Reset your password")
print(f"Click the link to reset your password: {reset_link}")
print("------------------------\n")
app/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
app/routers/auth.py
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.security import create_password_reset_token, decode_token, hash_password
from app.db.database import get_db
from app.schemas.password_reset import ForgotPasswordRequest, ResetPasswordRequest
from app.services.email_service import send_password_reset_email
from app.services.user_service import get_user_by_email
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
def forgot_password(
data: ForgotPasswordRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
user = get_user_by_email(db, data.email)
if user is not None:
reset_token = create_password_reset_token(user.email)
background_tasks.add_task(send_password_reset_email, user.email, reset_token)
# Always return the same generic message, whether or not the email exists.
return {"message": "If that email address is registered, a reset link has been sent."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
invalid_token_exception = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token.",
)
try:
payload = decode_token(data.token)
except ValueError:
raise invalid_token_exception
if payload.get("type") != "password_reset":
raise invalid_token_exception
email = payload.get("sub")
user = get_user_by_email(db, email) if email else None
if user is None:
raise invalid_token_exception
user.hashed_password = hash_password(data.new_password)
db.commit()
return {"message": "Your password has been reset successfully."}
app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.db.database import Base, engine
from app.models import user # noqa: F401
from app.routers import auth, users
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
app.include_router(auth.router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
create_password_reset_tokenlooks almost identical to Chapter 15’screate_access_token, but with two important differences: it uses the user’s email as the subject (not username — reset requests naturally start from an email address), and it embeds"type": "password_reset"— the same claim-based pattern from Chapter 15’s access/refresh tokens, so this token can never accidentally be accepted byget_current_useror any other part of the system expecting a different token type.send_password_reset_emaildeliberately includestime.sleep(2)to simulate the real-world latency of talking to an email provider — this makes the benefit ofBackgroundTasksgenuinely observable when you test it, rather than theoretical.background_tasks.add_task(send_password_reset_email, user.email, reset_token)— notice we pass the function itself and its arguments separately, rather than calling it directly. FastAPI stores this and runs it only after the HTTP response has already been sent to the client.- The anti-enumeration pattern is critical here:
forgot_passwordreturns the exact same message whetherget_user_by_emailfound a real user or not — the only difference is whether a background task was actually scheduled. From the client’s perspective (and from the perspective of anyone intercepting network traffic and only looking at response timing and content), there’s no way to distinguish “email exists” from “email doesn’t exist.” reset_passwordfollows the same defensive pattern from Chapter 15: one genericinvalid_token_exception, reused for every possible failure (bad signature, expired, wrong type, user no longer exists) — never revealing which specific thing went wrong.
Execution & Testing
uv run fastapi dev app/main.py
- Register a user via
POST /users/register. - Call
POST /auth/forgot-passwordwith that user’s email. Notice the response returns almost instantly — even though your terminal, a couple of seconds later, prints the simulated email (proving the 2-second delay happened safely in the background, not blocking the response). - Call
POST /auth/forgot-passwordagain with an email that was never registered — confirm you get the exact same generic response message, with no error and no way to tell the difference. - Copy the
tokenvalue printed in your terminal’s simulated email output, then callPOST /auth/reset-passwordwith{"token": "...", "new_password": "BrandNewPassword123"}— confirm you get a success message. - Try logging in (if you build a login endpoint following Chapter 14’s pattern) with the old password — confirm it fails — then with the new password — confirm it succeeds.
- Try reusing the exact same reset token again for a second
POST /auth/reset-passwordcall — notice it still works, since nothing marks a JWT as “already used.” Keep this limitation in mind — it’s exactly what Program 2 fixes.
Program 2: One-Time-Use, Revocable Reset Tokens
Program Objective
Fix Program 1’s key weakness by storing password reset tokens in the database instead of as self-contained JWTs — allowing tokens to be marked as used after a single successful reset, so a token can never be replayed a second time even if it hasn’t expired yet.
Step-by-Step Implementation
Step 1: Create the project
uv init secure-password-reset-demo
cd secure-password-reset-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
💡 Notice
python-joseisn’t needed this time — reset tokens are now random strings, not JWTs.
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
secure-password-reset-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── security.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── password_reset_token.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── password_reset.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ ├── email_service.py
│ │ └── password_reset_service.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ └── auth.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Secure Password Reset Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
password_reset_token_expire_minutes: int = 15
settings = Settings()
app/core/security.py
import hashlib
import secrets
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def generate_raw_reset_token() -> str:
"""A high-entropy random string, sent to the user and never stored as-is."""
return secrets.token_urlsafe(32)
def hash_reset_token(raw_token: str) -> str:
"""
A fast, deterministic hash (not bcrypt) is appropriate here because, unlike a
password, this token already has very high entropy and is used only once.
"""
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
app/models/password_reset_token.py
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime)
used: Mapped[bool] = mapped_column(Boolean, default=False)
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
model_config = ConfigDict(from_attributes=True)
app/schemas/password_reset.py
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: Annotated[str, Field(min_length=8, max_length=64)]
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()
app/services/email_service.py
import time
def send_password_reset_email(email: str, raw_token: str) -> None:
time.sleep(2) # simulate real network latency talking to an email provider
reset_link = f"https://example.com/reset-password?token={raw_token}"
print("\n--- Simulated Email ---")
print(f"To: {email}")
print("Subject: Reset your password")
print(f"Click the link to reset your password: {reset_link}")
print("This link can only be used once and expires in 15 minutes.")
print("------------------------\n")
app/services/password_reset_service.py
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import generate_raw_reset_token, hash_reset_token
from app.models.password_reset_token import PasswordResetToken
from app.models.user import User
def create_reset_token_for_user(db: Session, user: User) -> str:
raw_token = generate_raw_reset_token()
reset_token = PasswordResetToken(
user_id=user.id,
token_hash=hash_reset_token(raw_token),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=settings.password_reset_token_expire_minutes),
used=False,
)
db.add(reset_token)
db.commit()
return raw_token # the raw token is returned once, to be emailed — never stored as-is
def redeem_reset_token(db: Session, raw_token: str) -> User | None:
token_hash = hash_reset_token(raw_token)
reset_token = (
db.query(PasswordResetToken)
.filter(PasswordResetToken.token_hash == token_hash)
.first()
)
if reset_token is None:
return None
if reset_token.used:
return None
if reset_token.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
return None
reset_token.used = True
db.commit()
return db.get(User, reset_token.user_id)
app/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
app/routers/auth.py
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.db.database import get_db
from app.schemas.password_reset import ForgotPasswordRequest, ResetPasswordRequest
from app.services.email_service import send_password_reset_email
from app.services.password_reset_service import create_reset_token_for_user, redeem_reset_token
from app.services.user_service import get_user_by_email
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/forgot-password", status_code=status.HTTP_200_OK)
def forgot_password(
data: ForgotPasswordRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
user = get_user_by_email(db, data.email)
if user is not None:
raw_token = create_reset_token_for_user(db, user)
background_tasks.add_task(send_password_reset_email, user.email, raw_token)
return {"message": "If that email address is registered, a reset link has been sent."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
user = redeem_reset_token(db, data.token)
if user is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid, expired, or already-used reset token.",
)
user.hashed_password = hash_password(data.new_password)
db.commit()
return {"message": "Your password has been reset successfully."}
app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.db.database import Base, engine
from app.models import password_reset_token, user # noqa: F401
from app.routers import auth, users
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
app.include_router(auth.router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
PasswordResetTokenis a brand-new table — this is the core structural change from Program 1. Instead of the token being a self-contained, self-verifying JWT, it’s now just a reference to a row in this table, and that row can be updated (used = True) after redemption.generate_raw_reset_token()usessecrets.token_urlsafe(32)— Python’s standard library function for generating cryptographically secure random strings, appropriate for anything security-sensitive (unlike the regularrandommodule, which is not safe for this purpose).- Why hash the token before storing it? The same reasoning as password hashing: if your database were ever leaked, you wouldn’t want every currently-valid reset token sitting in plain, directly-usable text.
hash_reset_tokenuses a simple, fastsha256hash rather thanbcrypt— this is intentional, not a mistake: bcrypt’s slowness is a feature specifically for low-entropy, human-guessable passwords, but a 32-byte random token already has far more entropy than any password could, so a fast, deterministic hash is both appropriate and necessary here (we need to look the token up quickly by its hash). redeem_reset_tokenis where the real security upgrade happens — it checks, in order: does a token with this hash even exist? Has it already been used? Has it expired? Only if all three checks pass does it markused = Trueand return the associated user. Onceusedis set, this exact token can never succeed again, even if an attacker somehow captured it from an email and tried replaying it minutes later — a genuine security improvement over Program 1’s plain JWT, which remains valid and reusable for its entire lifetime.create_reset_token_for_userreturns the raw, unhashed token — this is the one and only moment the real token value exists outside the hash. It’s handed straight to the email background task and never touches application logs or storage in its raw form again.
Execution & Testing
uv run fastapi dev app/main.py
- Register a user, then call
POST /auth/forgot-passwordwith their email — confirm the quick response, followed a couple of seconds later by the simulated email in your terminal. - Copy the token from the printed reset link and call
POST /auth/reset-passwordwith it — confirm success. - Immediately try reusing the exact same token again with a different
new_password— confirm you now get a400 Invalid, expired, or already-used reset token, proving the one-time-use protection actually works this time, unlike Program 1. - Request a new reset token, but wait longer than
password_reset_token_expire_minutes(or temporarily lower that setting to0and restart the app) before attempting to redeem it — confirm it’s rejected as expired.
Tips & Instructions for Developers
- ✅ Use
BackgroundTasksfor anything slow that doesn’t need to finish before you respond to the client — sending emails, writing logs, notifying third-party services. - ✅ Always return the same generic response from a forgot-password endpoint, regardless of whether the email exists — this single detail prevents account enumeration.
- ✅ Prefer database-stored, hashable, one-time-use tokens over plain JWTs for anything sensitive and single-use, like password resets — the ability to mark a token “used” and prevent replay is a meaningful security improvement.
- ✅ Use
secrets.token_urlsafe(...)(never therandommodule) for anything security-sensitive that needs true randomness. - ✅ Keep reset token lifetimes short (15–30 minutes is typical) — the shorter the window, the smaller the risk if an email is somehow intercepted.
- ❌ Don’t send the raw password reset token anywhere except directly to the user’s verified email address — never log it, never return it in an API response body.
- ❌ Don’t skip testing the “reuse an already-used token” path — it’s the exact scenario that separates a merely functional password reset feature from a genuinely secure one.
Official FastAPI Documentation References
Theory Questions
- Why does
BackgroundTasksallow the client to receive a response before the email has actually finished sending? - Why must a forgot-password endpoint return the same message whether or not the email address is registered?
- Why is a fast hash (
sha256) appropriate for reset tokens, while a slow hash (bcrypt) is appropriate for passwords? - What specific security weakness in Program 1’s JWT-based reset token does Program 2’s database-stored token fix?
- Why is the raw reset token only ever available at the moment it’s generated, and never reconstructed from the stored hash later?
Practical Assignment
- Add a “resend” cooldown: Starting from Program 2’s project, prevent a user from requesting more than one password reset email within any 60-second window, by checking for a recent, unexpired, unused token before creating a new one.
- Invalidate old tokens on new request: Starting from Program 2’s project, modify
forgot_passwordso that requesting a new reset token automatically marks any previous, still-valid tokens for that user as used — ensuring only the most recently requested link can ever work. - Connect a real email service (research task): Look into a real transactional email provider suited for development testing (such as Mailtrap or a free tier of SendGrid), and write out, in your own words or as code comments, what would need to change in
email_service.pyto send a genuine email instead of printing to the console.
Summary
In this final chapter of the series, you learned:
- How to build a complete, secure forgot-password → reset-password flow
- How
BackgroundTaskslets your API respond instantly while slower work like sending email continues afterward - Why a forgot-password endpoint must never reveal whether an email address is registered
- Why database-stored, hashed, one-time-use reset tokens are a meaningful security upgrade over a simple JWT for this specific use case
