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_KEYand include anexpclaim. Theverify_password_reset_tokenfunction 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.