This tutorial implements a complete password reset flow in the FastAPI application. The application now supports user registration, login, post creation, editing, deletion, profile picture uploads, and pagination. However, a critical feature is missing: what happens when users forget their passwords or want to change them?

Implementing password reset requires sending emails without blocking the application. This tutorial covers sending emails asynchronously, using FastAPI’s background tasks for non-blocking operations, and creating secure reset tokens following security best practices. The complete flow spans from requesting a reset to receiving an email to setting a new password, and includes completing the account page so logged-in users can change passwords directly.

Security Principles for Password Reset

  • Security is paramount for password reset functionality. Several critical principles must be followed:
  • Tokens must be unguessable. They cannot be predictable values that attackers could guess. Random, cryptographically secure tokens are required.
  • Tokens must expire quickly. Best practice is one hour or less. Longer expiration windows increase vulnerability.
  • Tokens must be single-use. Once used to reset a password, they cannot be reused. This prevents replay attacks.
  • Email enumeration must be prevented. The system should not reveal whether specific email addresses exist in the database. This protects user privacy and prevents attackers from discovering valid accounts.

These principles guide implementation throughout this tutorial.

Installing Dependencies

The primary package needed is aiosmtplib for asynchronous email sending. Install it with pip:

uv add aiosmtplib

Why aiosmtplib instead of Python’s built-in smtplib? Python’s standard smtplib is synchronous. Since the FastAPI application is async, a synchronous SMTP library would block the event loop. An async-native SMTP library is required.

The email-validator package is also needed for Pydantic’s EmailStr type, but it’s already included if FastAPI was installed with standard extras. If FastAPI was installed without extras, email-validator must be installed separately.

Adding Email Configuration Settings

Open config.py to add email configuration. The existing settings class already contains secret key, access token expiration, and other configuration from the authentication tutorial.

Add reset token expiration first. Best practice is one hour or less:sett

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8"
    )
    
    secret_key: SecretStr
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 30
    max_upload_size_bytes: int = 5 * 1024 * 1024
    posts_per_page: int = 10
    reset_token_expire_minutes: int = 60  # New setting

Add email configuration settings:

    # Email settings
    mail_server: str = "localhost"  
	mail_port: int = 587  # Standard port for SMTP with STARTTLS  
	mail_username: str = ""  
	mail_password: SecretStr = SecretStr("")  
	mail_from: str = "noreply@example.com"  
	mail_use_tls: bool = True  
	frontend_url: str = "http://localhost:8000"

mail_server is the SMTP server hostname. mail_port is the port number—587 is standard for SMTP with STARTTLS. mail_username and mail_password are credentials for authenticating with the SMTP server. Notice SecretStr for the password, preventing it from being accidentally logged or printed (same pattern as the secret key).

mail_from is the sender address appearing in the “From” field of emails. mail_use_tls tells aiosmtplib whether to use STARTTLS encryption.

frontend_url is critical for security. This is the base URL used when building password reset links. It’s hardcoded in settings instead of pulled from incoming requests because request data can be manipulated by attackers. If URLs were built from request data, an attacker could potentially trick the system into sending reset emails with links pointing to their site instead of the legitimate application.

Setting Up Email Sandbox for Development

In production, a real email provider like SendGrid or AWS SES would be used. During development, real emails should not be sent. Mailtrap provides an email sandbox service that catches test emails instead of sending them to real addresses.

Mailtrap offers a free tier with 100 test emails per month—plenty for development. It’s cloud-based (nothing to install) and works regardless of operating system.

Navigate to mailtrap.io and create an account. After logging in, complete the onboarding questions. For “what you want to do,” choose “Email Sandbox.”

The dashboard displays available sandboxes. Click “Start Testing” to create a new sandbox. Name it (e.g., “FastAPI Blog”) and save. Click on the sandbox to view credentials.

Mailtrap provides a convenient click-to-copy system for each credential. Copy the credentials and add them to the .env file.

Configuring Environment Variables

Open .env and add the email configuration:

RESET_TOKEN_EXPIRE_MINUTES=60

MAIL_SERVER=sandbox.smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=your_mailtrap_username
MAIL_PASSWORD=your_mailtrap_password
MAIL_FROM=noreply@fastapiblog.com
MAIL_USE_TLS=true
FRONTEND_URL=http://localhost:8000

Replace your_mailtrap_username and your_mailtrap_password with the actual credentials from Mailtrap. The .env file must never be committed to version control—it should already be in .gitignore from the authentication tutorial.

Creating Email Utilities

Create a new file named email_utils.py in the project root. Add the necessary imports:

from email.message import EmailMessage  
  
import aiosmtplib  
from fastapi.templating import Jinja2Templates  
  
from config import settings

EmailMessage from Python’s standard library constructs email messages. aiosmtplib sends the emails. Jinja2 templates render HTML emails (just like they render web pages). Settings provide the email configuration.

Set up the template engine for email templates:

templates = Jinja2Templates(directory="templates")

This uses the same templates directory as web templates, but email templates will go in a subdirectory called templates/email for organization.

Creating the Send Email Function

Add the core email sending function:

async def send_email(
    to_email: str,
    subject: str,
    plain_text: str,
    html_content: str | None = None,
):
    # Construct message
    message = EmailMessage()
    message["From"] = settings.mail_from
    message["To"] = to_email
    message["Subject"] = subject
    
    # Set plain text content
    message.set_content(plain_text)
    
    # Add HTML alternative if provided
    if html_content:
        message.add_alternative(html_content, subtype="html")
    
    # Send email
    await aiosmtplib.send(
        message,
        hostname=settings.mail_server,
        port=settings.mail_port,
        username=settings.mail_username if settings.mail_username else None,  
		password=settings.mail_password.get_secret_value() or None,
        use_tls=settings.mail_use_tls,
    )

The function takes the recipient email address, subject, plain text content, and optional HTML content. It constructs an EmailMessage with the appropriate headers (From, To, Subject).

Plain text is always included as a fallback because some email clients don’t render HTML—they may have accessibility or security settings that disable HTML. If HTML content is provided, it’s added as an alternative using add_alternative() with subtype="html".

The function calls aiosmtplib.send() with the message and server configuration. Note get_secret_value() on the password—this extracts the actual string from the SecretStr type. All other parameters come directly from settings.

Creating the Password Reset Email Function

Add a helper function specifically for password reset emails:

async def send_password_reset_email(to_email: str, username: str, token: str) -> None:
    # Build reset URL
    reset_url = f"{settings.frontend_url}/reset-password?token={token}"
 
    # Render HTML template
    template = templates.env.get_template("email/password_reset.html")
    html_content = template.render(reset_url=reset_url, username=username)
 
    # Plain text fallback
    plain_text = f"""Hi {username},
    You requested to reset your password. Click the link below to set a new password:
    
    {reset_url}
    
    This link will expire in 1 hour.
    
    If you didn't request this, you can safely ignore this email.
    
    Best regards,
    The FastAPI Blog Team
    """
 
    # Send email
    await send_email(
        to_email=to_email,
        subject="Reset Your Password - FastAPI Blog",
        plain_text=plain_text,
        html_content=html_content,
    )

The function builds the reset URL using frontend_url from settings and the token. It renders the HTML template using templates.get_template() and calling render() on it. This differs from web pages where TemplateResponse is used. TemplateResponse requires a request object. For emails, there is no request—just the rendered string is needed. The method get_template() returns a template that can be called with render() to produce the string.

The template receives reset_url and username as context variables. Plain text provides a simple fallback with standard password reset language: greeting, link, expiration notice, and instruction to ignore if not requested.

The function calls send_email() with all components. The expiration time is hardcoded as “1 hour” for simplicity—it could be dynamically populated from settings.reset_token_expire_minutes if desired.

Creating the Email Template

Create a subdirectory named email inside templates to separate email templates from web templates. Inside templates/email, create password_reset.html.

Email templates differ from web templates in important ways:

  • Use inline CSS because many email clients strip out <style> tags
  • Use table-based layouts because not all clients support modern CSS
  • Avoid JavaScript because email clients typically ignore it

Add the template content (look at the repo for the code).

The template uses simple HTML with inline styles for font and background. Tables provide layout for reliable rendering across email clients. A header displays the blog name, the main body includes a greeting using the username variable, and a reset button uses the reset_url variable.

The expiration notice emphasizes the one-hour limit. A fallback link is provided at the bottom for email clients that might have trouble with the button. This is standard practice for email templates.

Creating the Database Model for Reset Tokens

Why not use JSON Web Tokens for password reset like login tokens? Best practice is to use random, single-use tokens stored securely in the database. JWTs cannot be invalidated before expiration unless a blacklist is maintained in the database, which defeats the purpose. With database-stored tokens, true single-use behavior exists by deleting tokens after successful resets, and they can be invalidated at any time by deleting them from the database (e.g., when users request new tokens).

Open models.py and add a new model for password reset tokens:

class PasswordResetToken(Base):
    __tablename__ = "password_reset_tokens"
    
    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
    token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(UTC)
    )
    
    user: Mapped[User] = relationship(back_populates="reset_tokens")

id is the primary key. user_id is a foreign key to the users table, linking to the user who requested the reset. token_hash stores the hashed token—note the 64-character length, which matches the output of the hashing function that will be used.

The hash is stored, not the actual token. This is critical for security. If someone gains database access, they see only hashes (useless without the original tokens), not the actual tokens.

expires_at tracks when the token expires. created_at records creation time. The user relationship links back to the User model.

Add the corresponding relationship to the User model:

class User(Base):
    __tablename__ = "users"
    
    # ... existing fields ...
    
    reset_tokens: Mapped[list[PasswordResetToken]] = relationship(
        back_populates="user",
        cascade="all, delete-orphan",
    )

The cascade="all, delete-orphan" ensures that when a user is deleted, all their reset tokens are automatically cleaned up.

Since a new model was added, the database must be recreated. In production, Alembic would be used for migrations (covered later in the series). For now, delete blog.db and let it recreate on startup.

Adding Token Generation and Hashing Utilities

Open auth.py to add token generation and hashing functions. Add imports:

import hashlib
import secrets

secrets is Python’s module for generating cryptographically secure random values. hashlib provides hashing functions.

Add the token generation function after verify_password():

def generate_reset_token() -> str:
    return secrets.token_urlsafe(32)

This uses secrets.token_urlsafe() with 32 bytes, producing URL-safe Base64 characters—perfect for email links.

Add the token hashing function:

def hash_reset_token(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()

This takes a token and returns its SHA-256 hash.

Why SHA-256 instead of Argon2 (used for passwords)? Passwords are often weak and predictable, requiring slow hashing to make brute-force attacks impractical. Reset tokens are already random—there’s no way to brute-force them. SHA-256 is perfectly fine for this use case and much faster than Argon2.

Adding Request Schemas

Open schemas.py and add three new schemas at the bottom for the password reset endpoints:

class ForgotPasswordRequest(BaseModel):
    email: EmailStr = Field(max_length=120)
 
 
class ResetPasswordRequest(BaseModel):
    token: str
    new_password: str = Field(min_length=8)
 
 
class ChangePasswordRequest(BaseModel):
    current_password: str
    new_password: str = Field(min_length=8)

ForgotPasswordRequest is for the forgot password endpoint—it only needs an email. ResetPasswordRequest is for when users click the link and submit their new password—it needs the token from the URL and the new password. ChangePasswordRequest is for logged-in users changing their password—it needs the current password for verification and the new password.

Understanding FastAPI Background Tasks

Sending emails takes time. Substantial back-and-forth occurs between the application server and the email server. Users should not wait for this process. The application should return a response immediately and send the email in the background.

FastAPI’s background tasks enable exactly this. The application returns a response to the client, then FastAPI runs the task after the response is sent. If the server crashes, pending background tasks are lost. For critical operations, a task queue like Celery would be used. For password reset emails, background tasks are perfectly acceptable—users can always request another email if needed.

Implementing API Endpoints

Open routers/users.py to implement the password reset endpoints. Add necessary imports:

from datetime import UTC, datetime, timedelta
 
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy import delete as sql_delete
 
from auth import (
    create_access_token,
    hash_password,
    oauth2_scheme,
    CurrentUser,
    generate_reset_token,
    hash_reset_token,
    verify_password,
)
from email_utils import send_password_reset_email
from schemas import (
    PostResponse,
    Token,
    UserCreate,
    UserPrivate,
    UserPublic,
    UserUpdate,
    ForgotPasswordRequest,
    ResetPasswordRequest,
    ChangePasswordRequest,
)

BackgroundTasks enables background task scheduling. The delete function from SQLAlchemy is imported as sql_delete to avoid naming conflicts. Token generation and hashing utilities are imported from auth. The email sending function and new schemas are included.

Implementing Forgot Password

Add the forgot password endpoint after get_current_user:

@router.post("/forgot-password", status_code=status.HTTP_202_ACCEPTED)  
async def forgot_password(  
    request_data: ForgotPasswordRequest,  
    background_tasks: BackgroundTasks,  
    db: Annotated[AsyncSession, Depends(get_db)],  
):  
    # Look up user by email (case-insensitive)  
    result = await db.execute(  
        select(models.User).where(  
            func.lower(models.User.email) == request_data.email.lower(),  
        ),  
    )  
    user = result.scalars().first()  
  
    if user:  
        # Delete any existing reset tokens for this user  
        await db.execute(  
            sql_delete(models.PasswordResetToken).where(  
                models.PasswordResetToken.user_id == user.id,  
            ),  
        )  
  
        # Generate new token  
        token = generate_reset_token()  
        token_hash = hash_reset_token(token)  
        expires_at = datetime.now(UTC) + timedelta(  
            minutes=settings.reset_token_expire_minutes,  
        )  
  
        #  Create password reset token  
        reset_token = models.PasswordResetToken(  
            user_id=user.id,  
            token_hash=token_hash,  
            expires_at=expires_at,  
        )  
        db.add(reset_token)  
        await db.commit()  
  
        # Schedule email to be sent in background  
        background_tasks.add_task(  
            send_password_reset_email,  
            to_email=user.email,  
            username=user.username,  
            token=token,  
        )  
  
    # Always return same response (security: don't reveal if email exists)  
    return {  
        "message": "If an account exists with this email, you will receive password reset instructions.",  
    }

The status code is 202 Accepted instead of 200 OK. 202 means “request accepted and will be processed”—it doesn’t confirm whether the email exists.

The endpoint looks up the user by email (case-insensitive). If the user exists, it deletes any existing reset tokens for them (invalidating old requests), generates a new token, hashes it, sets the expiration time using the setting from config, creates a PasswordResetToken record with the hashed token and expiration, and schedules the email to be sent in the background using background_tasks.add_task().

Critical: The unhashed token is passed to the email function. This goes in the email link. The user needs the original token to complete the reset, but the hashed version is stored in the database.

Note that simple data (strings) is passed, not the database session. Background tasks run after the response is sent, so the session may be closed by then.

The return statement is critical for security. The same 202 response with the same generic message is always returned, regardless of whether the email exists. The response never says “email not found” or similar. This prevents email enumeration attacks where attackers try many emails to see which ones produce different responses, revealing which emails exist in the system.

Implementing Reset Password

Add the reset password endpoint after forgot password:

@router.post("/reset-password", status_code=status.HTTP_200_OK)  
async def reset_password(  
    request_data: ResetPasswordRequest,  
    db: Annotated[AsyncSession, Depends(get_db)],  
):  
    # Hash the submitted token  
    token_hash = hash_reset_token(request_data.token)  
  
    # Look up token by hash  
    result = await db.execute(  
        select(models.PasswordResetToken).where(  
            models.PasswordResetToken.token_hash == token_hash,  
        ),  
    )  
    reset_token = result.scalars().first()  
  
    if not reset_token:  
        raise HTTPException(  
            status_code=status.HTTP_400_BAD_REQUEST,  
            detail="Invalid or expired reset token",  
        )  
  
    # Check if token is expired (SQLite quirk: need to add timezone for comparison)  
    if reset_token.expires_at.replace(tzinfo=UTC) < datetime.now(UTC):  
        await db.delete(reset_token)  
        await db.commit()  
        raise HTTPException(  
            status_code=status.HTTP_400_BAD_REQUEST,  
            detail="Invalid or expired reset token",  
        )  
  
    # Look up user  
    result = await db.execute(  
        select(models.User).where(models.User.id == reset_token.user_id),  
    )  
    user = result.scalars().first()  
  
    if not user:  
        raise HTTPException(  
            status_code=status.HTTP_400_BAD_REQUEST,  
            detail="Invalid or expired reset token",  
        )  
  
    # Update password  
    user.password_hash = hash_password(request_data.new_password)  
  
    # Delete all reset tokens for this user  
    await db.execute(  
        sql_delete(models.PasswordResetToken).where(  
            models.PasswordResetToken.user_id == user.id,  
        ),  
    )  
  
    await db.commit()  
    return {  
        "message": "Password reset successfully. You can now log in with your new password.",  
    }

This endpoint uses 200 OK status code. Unlike forgot password (which deliberately hides whether anything happened), this endpoint provides direct feedback—the reset either worked or didn’t.

The endpoint hashes the submitted token to compare with database values, looks up the token by hash, and returns a generic error if not found. Again, the error doesn’t reveal too much information—it says “invalid or expired” without specifying which.

The expiration check includes an SQLite quirk. replace(tzinfo=UTC) adds timezone information back to the datetime. SQLite stores datetimes without timezone information even though the column was declared with timezone=True. SQLite strips it out. When reading it back, it lacks timezone information and cannot be compared directly with datetime.now(UTC). The replace() call adds the timezone back for comparison. This workaround won’t be needed after switching to PostgreSQL in the next tutorial—another reason PostgreSQL is more production-ready.

If the token is expired, it’s deleted and an error is returned. The user is looked up, and if they don’t exist, a generic error is returned. If everything is valid, the password hash is updated, all reset tokens for the user are deleted (not just the one used—this invalidates any other outstanding tokens), and a success message is returned.

The endpoint does not automatically log the user in. Best practice is to require normal login after password reset. This reduces complexity and potential security issues.

Implementing Change Password

Add the change password endpoint for logged-in users:

@router.patch("/me/password", status_code=status.HTTP_200_OK)  
async def change_password(  
    password_data: ChangePasswordRequest,  
    current_user: CurrentUser,  
    db: Annotated[AsyncSession, Depends(get_db)],  
):  
    # Verify current password  
    if not verify_password(password_data.current_password, current_user.password_hash):  
        raise HTTPException(  
            status_code=status.HTTP_400_BAD_REQUEST,  
            detail="Current password is incorrect",  
        )  
  
    # Update password  
    current_user.password_hash = hash_password(password_data.new_password)  
  
    # Delete any outstanding reset tokens  
    await db.execute(  
        sql_delete(models.PasswordResetToken).where(  
            models.PasswordResetToken.user_id == current_user.id,  
        ),  
    )  
  
    await db.commit()  
    return {"message": "Password changed successfully"}

The route is /me/password instead of /{user_id}/password. Password change is inherently a self-operation. The current user is already known from the token. Using /me means no separate authorization check is needed—it’s cleaner than passing a user ID when already authenticated.

The CurrentUser dependency requires the user to be logged in. The endpoint verifies the current password before allowing the change—this prevents someone who gained access to a logged-in session from immediately changing the password without knowing the current one.

If the current password is wrong, a 400 error is returned. If correct, the password hash is updated and any outstanding reset tokens for the user are deleted. If users requested a reset but then remembered their password and changed it normally, those reset tokens become invalid.

Testing the Backend API

Since the database was deleted earlier, repopulate it with sample data. The populate_db.py script from previous tutorials has been updated to work with the new password reset token model. Run it:

python populate_db.py

The script reports creating users and posts. Start the development server and navigate to /docs.

Testing Forgot Password

Expand the POST /api/users/forgot-password endpoint and click “Try it out”. Enter an email address (e.g., CoreyMSchafer@gmail.com from the sample data). Execute the request.

The response returns 202 Accepted with the generic message: “If an account exists with this email, you will receive password reset instructions.”:

Open Mailtrap and check the email sandbox. An email should appear within seconds. Open it to see the password reset message with a “Reset Password” button and the reset link containing the token in the URL:

Clicking the button would link to a frontend page that doesn’t exist yet. The frontend templates will be built shortly. First, demonstrate completing the flow entirely through the API.

Testing Reset Password via API

Copy the token from the reset link in the email. In the API documentation, locate the POST /api/users/reset-password endpoint and click “Try it out”. Paste the token into the token field. Enter a new password (e.g., NewPassword1!):

Execute the request. The response returns 200 OK with “Password reset successfully”:

Verify by logging in with the new password. Click “Authorize” at the top of the docs. Enter the email address and the new password (NewPassword1!). Click “Authorize”. The login succeeds.

Test the /api/users/me endpoint to confirm the current user. Click “Try it out” and execute. The response shows the authenticated user with the correct email.

The entire flow works through the API alone. The user could complete this from a mobile app, command-line tool, or any HTTP client. Frontend templates are just one way to interact with these endpoints.

Building Frontend Templates

Adding Frontend Routes

Open main.py and add routes for the forgot password and reset password pages after the account route:

@app.get("/forgot-password", include_in_schema=False)
async def forgot_password_page(request: Request):
    return templates.TemplateResponse(
        request,
        "forgot_password.html",
        {"title": "Forgot Password"},
    )
 
 
@app.get("/reset-password", include_in_schema=False)
async def reset_password_page(request: Request):
    response = templates.TemplateResponse(
        request,
        "reset_password.html",
        {"title": "Reset Password"},
    )
    response.headers["Referrer-Policy"] = "no-referrer"
    return response

The forgot password page route is straightforward—it simply returns the template.

The reset password page includes a security measure: Referrer-Policy: no-referrer. When clicking a link on a web page, browsers normally send a Referer header to the new site showing which page was visited. The reset password URL contains the token as a query parameter. If users click any link on that page, the browser could send the URL (including the token) to the other site via the referrer header. Setting no-referrer prevents this.

Creating the Forgot Password Template

Create templates/forgot_password.html (look at the repo for the code).

The HTML contains a simple form with an email input and a link back to the login page. JavaScript handles form submission by posting to the /api/users/forgot-password endpoint. On a 202 response, it shows the same generic success message (never revealing whether the email exists). On error, it displays the error message.

Creating the Reset Password Template

Create templates/reset_password.html (look at the repo for the code).

The HTML contains two password fields with confirmation checking. JavaScript extracts the token from the URL query parameter (the user arrives here by clicking the link in the email). If no token exists, an error is shown and the form is disabled.

On submit, passwords are checked for matching. If they match, the token and new password are sent to the /api/users/reset-password endpoint. On success, users are redirected to login. On error, the error message is displayed.

Open templates/login.html and add a “Forgot Password” link below the login form (look at the repo for the code).

This provides two links: one for password reset and one for registration.

Updating the Account Page

Open templates/account.html and locate the password change section (currently a placeholder). Replace it with a working form (look at the repo for the code).

This differs from the forgot password flow because it requires the current password. The user is already logged in, but they must prove they know the current password before changing it.

Add the JavaScript handler in the scripts block (look at the repo for the code).

JavaScript validates that passwords match, sends a POST request to /api/users/me/password with current and new passwords, and clears the form on success. Error handling covers 401 (redirect to login) and other errors (display message).

Testing the Complete Flow

Restart the server and navigate to the login page. A “Forgot your password?” link now appears. Click it.

Enter an email address (e.g., corey@example.com) and click “Send Reset Link”. The success message appears.

Check Mailtrap. A new email arrives within seconds. Open it and click the “Reset Password” button.

The frontend template now exists, displaying the reset password form. The URL contains the token as a query parameter. Enter a new password (e.g., testpassword1!) in both fields and click “Reset Password”.

The success message appears: “Password reset successfully. You can now log in with your new password.” The application redirects to login.

Log in with the email and new password. Login succeeds.

Navigate to the account page. The password change section now contains a working form. Enter the current password (testpassword1!), a new password (newpassword1!), and confirm it. Click “Change Password”.

The success message appears: “Password changed successfully.”

Log out and log back in with the new password to verify it works.

Summary

This tutorial implemented a complete password reset system for the FastAPI application. aiosmtplib was installed for async email sending. Mailtrap was set up as an email sandbox for development testing. A password reset token model was created with secure hashed token storage (not the tokens themselves).

Three API endpoints were added: forgot password (accepts email, sends reset link), reset password (accepts token and new password), and change password (for logged-in users). FastAPI’s background tasks were used for non-blocking email sending—responses return immediately while emails send in the background.

Frontend routes and templates provide a convenient web interface for the API endpoints, but the API works independently—mobile apps, CLI tools, or any HTTP client can use these endpoints directly.

Critical security principles implemented: Tokens are unguessable (cryptographically random), expire within one hour, are single-use (deleted after successful reset), and are stored as hashes (not plain tokens). Email enumeration is prevented by always returning the same generic message regardless of whether emails exist.

The next tutorial sets up a production database with PostgreSQL and Alembic for database migrations. Currently, SQLite is used (great for development), but PostgreSQL is the typical production choice. Alembic manages database schema changes without losing data—an important step before deploying the application.