Skip to main content

Password Recovery Workflow

In this tutorial, you will implement a complete password recovery workflow. This process allows users to request a password reset link via email, which directs them to a secure page where they can set a new password.

Prerequisites​

Before starting, ensure your backend is configured to send emails. In your .env file, you must provide valid SMTP credentials:

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_TLS=True
SMTP_USER=user@example.com
SMTP_PASSWORD=your-password
EMAILS_FROM_EMAIL=noreply@example.com
EMAILS_FROM_NAME="My App Support"
FRONTEND_HOST=http://localhost:5173

Step 1: Requesting a Reset (Frontend)​

The workflow begins on the frontend, where a user submits their email address. You use the LoginService.recoverPassword method from the generated SDK to trigger the process.

In frontend/src/routes/recover-password.tsx, the form submission is handled by a TanStack Query mutation:

import { useMutation } from "@tanstack/react-query"
import { LoginService } from "@/client"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"

// ... inside the component
const { showSuccessToast, showErrorToast } = useCustomToast()

const mutation = useMutation({
mutationFn: (data: { email: string }) =>
LoginService.recoverPassword({ email: data.email }),
onSuccess: () => {
showSuccessToast("If that email is registered, we sent a password recovery link")
},
onError: handleError.bind(showErrorToast),
})

const onSubmit = (data: { email: string }) => {
mutation.mutate(data)
}

When mutation.mutate is called, it sends a POST request to /api/v1/password-recovery/{email}.

Step 2: Generating the Secure Token (Backend)​

On the backend, the recover_password endpoint in backend/app/api/routes/login.py handles the request. It generates a time-limited JWT token containing the user's email.

@router.post("/password-recovery/{email}")
def recover_password(email: str, session: SessionDep) -> Message:
user = crud.get_user_by_email(session=session, email=email)
if user:
# Generate a JWT token valid for a specific duration (e.g., 24h)
password_reset_token = generate_password_reset_token(email=email)

# Prepare and send the email
email_data = generate_reset_password_email(
email_to=user.email, email=email, token=password_reset_token
)
send_email(
email_to=user.email,
subject=email_data.subject,
html_content=email_data.html_content,
)

# Always return success to prevent email enumeration
return Message(
message="If that email is registered, we sent a password recovery link"
)

The generate_password_reset_token function in backend/app/utils.py creates the signed token:

def generate_password_reset_token(email: str) -> str:
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
now = datetime.now(timezone.utc)
expires = now + delta
exp = expires.timestamp()
encoded_jwt = jwt.encode(
{"exp": exp, "nbf": now, "sub": email},
settings.SECRET_KEY,
algorithm=security.ALGORITHM,
)
return encoded_jwt

Step 3: Sending the Recovery Email​

The system uses Jinja2 to render HTML templates located in backend/app/email-templates/build/. The generate_reset_password_email utility constructs a link that includes the token as a query parameter.

# backend/app/utils.py
def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Password recovery for user {email}"
# The link points to the frontend reset-password route
link = f"{settings.FRONTEND_HOST}/reset-password?token={token}"
html_content = render_email_template(
template_name="reset_password.html",
context={
"project_name": settings.PROJECT_NAME,
"username": email,
"email": email_to,
"valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS,
"link": link,
},
)
return EmailData(html_content=html_content, subject=subject)

Step 4: Resetting the Password (Frontend)​

When the user clicks the link in their email, they are directed to /reset-password?token=.... The frontend extracts this token and allows the user to enter a new password.

In frontend/src/routes/reset-password.tsx, the token is retrieved from the URL search parameters:

export const Route = createFileRoute("/reset-password")({
validateSearch: (search) => z.object({ token: z.string() }).parse(search),
})

function ResetPassword() {
const { token } = Route.useSearch()
const navigate = useNavigate()

const mutation = useMutation({
mutationFn: (data: { new_password: string; token: string }) =>
LoginService.resetPassword({ requestBody: data }),
onSuccess: () => {
showSuccessToast("Password updated successfully")
navigate({ to: "/login" })
},
onError: handleError.bind(showErrorToast),
})

const onSubmit = (data: FormData) => {
mutation.mutate({ new_password: data.new_password, token })
}
// ... form rendering
}

Step 5: Finalizing the Change (Backend)​

The final step occurs when the backend receives the new password and the token. It verifies the token's signature and expiration before updating the database.

In backend/app/api/routes/login.py:

@router.post("/reset-password/")
def reset_password(session: SessionDep, body: NewPassword) -> Message:
# 1. Verify the token and extract the email
email = verify_password_reset_token(token=body.token)
if not email:
raise HTTPException(status_code=400, detail="Invalid token")

# 2. Find the user
user = crud.get_user_by_email(session=session, email=email)
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="Invalid token or inactive user")

# 3. Update the password
user_in_update = UserUpdate(password=body.new_password)
crud.update_user(session=session, db_user=user, user_in=user_in_update)

return Message(message="Password updated successfully")

Security Considerations​

  • Email Enumeration: The recovery endpoint always returns the same success message, even if the email is not found in the database. This prevents attackers from verifying which emails are registered.
  • Token Expiration: Tokens are signed with your SECRET_KEY and include an exp claim. The verify_password_reset_token function will fail if the token has expired or been tampered with.
  • Superuser Preview: For debugging, superusers can access /api/v1/password-recovery-html-content/{email} to see exactly what the recovery email looks like without actually sending it.