Chapter 21: Password Reset — Email, Tokens, and Background Tasks

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 BackgroundTasks so 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_token looks almost identical to Chapter 15’s create_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 by get_current_user or any other part of the system expecting a different token type.
  • send_password_reset_email deliberately includes time.sleep(2) to simulate the real-world latency of talking to an email provider — this makes the benefit of BackgroundTasks genuinely 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_password returns the exact same message whether get_user_by_email found 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_password follows the same defensive pattern from Chapter 15: one generic invalid_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
  1. Register a user via POST /users/register.
  2. Call POST /auth/forgot-password with 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).
  3. Call POST /auth/forgot-password again 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.
  4. Copy the token value printed in your terminal’s simulated email output, then call POST /auth/reset-password with {"token": "...", "new_password": "BrandNewPassword123"} — confirm you get a success message.
  5. 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.
  6. Try reusing the exact same reset token again for a second POST /auth/reset-password call — 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-jose isn’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

  • PasswordResetToken is 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() uses secrets.token_urlsafe(32) — Python’s standard library function for generating cryptographically secure random strings, appropriate for anything security-sensitive (unlike the regular random module, 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_token uses a simple, fast sha256 hash rather than bcrypt — 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_token is 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 mark used = True and return the associated user. Once used is 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_user returns 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
  1. Register a user, then call POST /auth/forgot-password with their email — confirm the quick response, followed a couple of seconds later by the simulated email in your terminal.
  2. Copy the token from the printed reset link and call POST /auth/reset-password with it — confirm success.
  3. Immediately try reusing the exact same token again with a different new_password — confirm you now get a 400 Invalid, expired, or already-used reset token, proving the one-time-use protection actually works this time, unlike Program 1.
  4. Request a new reset token, but wait longer than password_reset_token_expire_minutes (or temporarily lower that setting to 0 and restart the app) before attempting to redeem it — confirm it’s rejected as expired.

Tips & Instructions for Developers

  • ✅ Use BackgroundTasks for 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 the random module) 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

  1. Why does BackgroundTasks allow the client to receive a response before the email has actually finished sending?
  2. Why must a forgot-password endpoint return the same message whether or not the email address is registered?
  3. Why is a fast hash (sha256) appropriate for reset tokens, while a slow hash (bcrypt) is appropriate for passwords?
  4. What specific security weakness in Program 1’s JWT-based reset token does Program 2’s database-stored token fix?
  5. 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

  1. 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.
  2. Invalidate old tokens on new request: Starting from Program 2’s project, modify forgot_password so 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.
  3. 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.py to 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 BackgroundTasks lets 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
Scroll to Top