Password Reset Flow Implementation
The essential playbook for implementing password reset flow implementation in your SaaS.
Set up a password reset flow that is secure, simple to ship, and production-safe. This page covers the full implementation: reset request endpoint, token generation and storage, email link delivery, reset confirmation endpoint, password update, token invalidation, and the checks needed to avoid account takeover bugs.
Quick Fix / Quick Setup
Use this baseline if you need to ship a secure reset flow quickly.
# FastAPI example: secure password reset flow using signed tokens + DB-backed reset record
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
import secrets, hashlib
app = FastAPI()
# Replace with real DB operations
USERS = {
"user@example.com": {"id": 1, "email": "user@example.com", "password_hash": "old_hash"}
}
PASSWORD_RESETS = {}
RESET_TOKEN_TTL_MINUTES = 30
class ResetRequest(BaseModel):
email: EmailStr
class ResetConfirm(BaseModel):
token: str
new_password: str
def hash_password(password: str) -> str:
# Replace with bcrypt/argon2
return hashlib.sha256(password.encode()).hexdigest()
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def send_reset_email(email: str, token: str):
reset_url = f"https://app.example.com/reset-password?token={token}"
print(f"Send email to {email}: {reset_url}")
@app.post("/auth/password-reset/request")
def request_password_reset(payload: ResetRequest):
# Always return the same response to avoid user enumeration
user = USERS.get(payload.email)
if user:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
PASSWORD_RESETS[token_hash] = {
"user_id": user["id"],
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=RESET_TOKEN_TTL_MINUTES),
"used_at": None
}
send_reset_email(user["email"], raw_token)
return {"message": "If the account exists, a reset link has been sent."}
@app.post("/auth/password-reset/confirm")
def confirm_password_reset(payload: ResetConfirm):
token_hash = hash_token(payload.token)
reset_row = PASSWORD_RESETS.get(token_hash)
if not reset_row:
raise HTTPException(status_code=400, detail="Invalid or expired token")
if reset_row["used_at"] is not None:
raise HTTPException(status_code=400, detail="Invalid or expired token")
if reset_row["expires_at"] < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired token")
if len(payload.new_password) < 12:
raise HTTPException(status_code=400, detail="Password too short")
for email, user in USERS.items():
if user["id"] == reset_row["user_id"]:
user["password_hash"] = hash_password(payload.new_password)
reset_row["used_at"] = datetime.now(timezone.utc)
return {"message": "Password updated successfully"}
raise HTTPException(status_code=400, detail="Invalid or expired token")Use a one-time token, store only the token hash, expire it quickly, always return a generic response on the request endpoint, and invalidate active sessions after a successful reset.
What’s happening
A password reset flow has two parts:
POST /auth/password-reset/requestPOST /auth/password-reset/confirm
The request endpoint accepts an email address from a logged-out user. If the user exists, the backend creates a one-time token with an expiration time, stores only the token hash, and emails the raw token in a reset link.
The confirm endpoint accepts the raw token and a new password. The backend hashes the submitted token, finds the reset record, verifies that it exists and is still valid, updates the password hash, marks the token as used, and revokes active sessions.
Core rules:
- Do not reveal whether the email exists.
- Do not store raw reset tokens.
- Do not allow token reuse.
- Do not keep old sessions valid after reset.
sequence diagram showing request, token creation, email delivery, confirm, password update, and session revocation.
Step-by-step implementation
1) Create a password reset table
Use a dedicated table so you can enforce single use, expiration, cleanup, and auditability.
create table password_resets (
id bigserial primary key,
user_id bigint not null references users(id) on delete cascade,
token_hash text not null unique,
expires_at timestamptz not null,
used_at timestamptz null,
created_at timestamptz not null default now(),
requested_ip inet null,
user_agent text null
);
create index idx_password_resets_user_id on password_resets(user_id);
create index idx_password_resets_expires_at on password_resets(expires_at);Recommended related user/session fields:
alter table users
add column if not exists password_changed_at timestamptz;
-- Example refresh token table
create table if not exists refresh_tokens (
id bigserial primary key,
user_id bigint not null references users(id) on delete cascade,
token_hash text not null,
revoked_at timestamptz null,
created_at timestamptz not null default now()
);2) Add the request endpoint
Always return the same response.
from datetime import datetime, timedelta, timezone
import hashlib
import secrets
RESET_TOKEN_TTL_MINUTES = 30
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
@app.post("/auth/password-reset/request")
def request_password_reset(payload: ResetRequest, request: Request):
user = db_get_user_by_email(payload.email)
if user:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=RESET_TOKEN_TTL_MINUTES)
db_insert_password_reset(
user_id=user["id"],
token_hash=token_hash,
expires_at=expires_at,
requested_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
send_reset_email(
email=user["email"],
reset_url=f"{FRONTEND_URL}/reset-password?token={raw_token}"
)
return {"message": "If the account exists, a reset link has been sent."}Important:
- Generate with
secrets.token_urlsafe(32)or equivalent. - Store only
token_hash. - Use your production frontend URL.
- Do not log the raw token.
3) Send the email
Use a transactional provider such as Postmark, Resend, SendGrid, SES, or Mailgun.
Example environment config:
FRONTEND_URL=https://app.example.com
RESET_TOKEN_TTL_MINUTES=30
EMAIL_FROM=no-reply@example.comExample reset link format:
https://app.example.com/reset-password?token=<raw-token>Minimal email content:
Subject: Reset your password
If you requested a password reset, click the link below:
https://app.example.com/reset-password?token=<raw-token>
This link expires in 30 minutes. If you did not request this, you can ignore this email.If email sending is slow, move it to a background job or queue.
4) Build the reset page
The frontend should:
- Read
tokenfrom the query string. - Render new password fields.
- Post
tokenandnew_passwordto the backend. - Show a generic token error if invalid or expired.
Example request body:
{
"token": "RAW_TOKEN_HERE",
"new_password": "StrongPassword123!"
}5) Add the confirm endpoint
Hash the provided token, validate it, update the password, and mark the token as used in one transaction.
from fastapi import HTTPException
from argon2 import PasswordHasher
ph = PasswordHasher()
@app.post("/auth/password-reset/confirm")
def confirm_password_reset(payload: ResetConfirm):
token_hash = hash_token(payload.token)
with db_transaction() as tx:
reset_row = tx.get_password_reset_by_token_hash(token_hash)
if not reset_row:
raise HTTPException(status_code=400, detail="Invalid or expired token")
if reset_row["used_at"] is not None:
raise HTTPException(status_code=400, detail="Invalid or expired token")
if reset_row["expires_at"] < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invalid or expired token")
validate_password_policy(payload.new_password)
user = tx.get_user_by_id(reset_row["user_id"])
if not user:
raise HTTPException(status_code=400, detail="Invalid or expired token")
new_password_hash = ph.hash(payload.new_password)
tx.update_user_password(
user_id=user["id"],
password_hash=new_password_hash,
password_changed_at=datetime.now(timezone.utc),
)
tx.mark_password_reset_used(
reset_id=reset_row["id"],
used_at=datetime.now(timezone.utc),
)
tx.revoke_all_refresh_tokens(user_id=user["id"])
tx.delete_all_sessions(user_id=user["id"])
return {"message": "Password updated successfully"}6) Enforce password policy
Keep it simple and explicit.
from fastapi import HTTPException
def validate_password_policy(password: str):
if len(password) < 12:
raise HTTPException(status_code=400, detail="Password must be at least 12 characters")If you have enterprise requirements, add breached-password checks and complexity checks, but for small SaaS products, strong minimum length plus modern hashing is usually enough.
7) Revoke sessions after reset
This is required for most apps.
If you use DB-backed sessions:
delete from sessions where user_id = $1;If you use refresh tokens:
update refresh_tokens
set revoked_at = now()
where user_id = $1 and revoked_at is null;If you use JWT access tokens plus refresh tokens:
- Revoke refresh tokens server-side.
- Force re-authentication.
- Keep access token TTL short.
See also:
8) Clean up old reset rows
Run a scheduled cleanup job.
delete from password_resets
where expires_at < now() - interval '7 days'
or used_at is not null;Example cron:
0 * * * * psql "$DATABASE_URL" -c "delete from password_resets where expires_at < now() - interval '7 days' or used_at is not null;"Common causes
These are the most common implementation mistakes:
- Returning different responses for existing vs non-existing emails, which leaks user enumeration.
- Storing raw reset tokens in the database.
- Failing to expire tokens or allowing token reuse.
- Not invalidating existing sessions after password reset.
- Using weak password hashing or storing passwords incorrectly.
- Building reset links with the wrong frontend domain or environment variable.
- Email provider misconfiguration causing reset emails not to send.
- Timezone bugs causing tokens to appear expired immediately or never expire.
- Not rate limiting reset requests, leading to abuse or email flooding.
- Frontend sends token incorrectly due to URL encoding or wrong field names.
Debugging tips
Use these commands to test the flow.
Test request endpoint
curl -X POST https://api.example.com/auth/password-reset/request \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com"}'Test confirm endpoint
curl -X POST https://api.example.com/auth/password-reset/confirm \
-H 'Content-Type: application/json' \
-d '{"token":"RAW_TOKEN_HERE","new_password":"StrongPassword123!"}'Generate a sample token
python - <<'PY'
import secrets
print(secrets.token_urlsafe(32))
PYCheck current UTC time
python - <<'PY'
from datetime import datetime, timezone
print(datetime.now(timezone.utc).isoformat())
PYInspect logs
grep -i 'reset' /var/log/app.log
journalctl -u your-app-service -n 200 --no-pager
docker compose logs -f web
docker compose logs -f workerInspect database rows
psql "$DATABASE_URL" -c "select user_id, expires_at, used_at, created_at from password_resets order by created_at desc limit 20;"
psql "$DATABASE_URL" -c "select id, email, password_changed_at from users where email = 'user@example.com';"What to verify during debugging
- Request endpoint returns the same body for existing and missing users.
- Reset email uses the correct
FRONTEND_URL. expires_atis UTC and not a naive timestamp.- Submitted token is hashed exactly once before lookup.
- Token row is marked used after password update.
- Existing sessions or refresh tokens are revoked after successful reset.
Checklist
- ✓ Request endpoint returns a generic message for both existing and non-existing emails.
- ✓ Raw reset token is never stored in the database.
- ✓ Reset tokens expire and are single use.
- ✓ New passwords are hashed with Argon2id or bcrypt.
- ✓ Password reset invalidates existing sessions or refresh tokens.
- ✓ Reset emails use the correct production frontend URL.
- ✓ Rate limits are enabled on reset request and confirm endpoints.
- ✓ Expired and used tokens are cleaned up regularly.
- ✓ Audit logs capture reset events without sensitive token values.
- ✓ Password updates and token consumption happen in one transaction.
- ✓ Password policy is enforced on reset.
- ✓ Email provider is configured for production delivery.
For broader release checks, review SaaS Production Checklist.
Related guides
- Implement User Authentication (Login/Register)
- Password Hashing and Security Best Practices
- Email Verification Flow (Step-by-Step)
- Session Management vs JWT (When to Use What)
- SaaS Production Checklist
FAQ
Should reset tokens be stored in plaintext?
No. Store only a hash of the token. Email the raw token to the user and compare by hashing the submitted token on confirm.
How do I prevent email enumeration?
Always return the same message from the password reset request endpoint whether or not the email exists.
Should I revoke sessions after password reset?
Yes. Revoke server-side sessions or refresh tokens so old sessions cannot continue after the password changes.
What token lifetime should I use?
A short lifetime is standard. Start with 30 minutes unless your product has a clear reason to shorten or extend it.
Can I implement password reset without a database table?
You can with signed tokens, but a DB-backed reset record gives you single-use enforcement, revocation, and auditability more easily.
Final takeaway
A production-safe password reset flow is small but security-critical.
Use one-time short-lived tokens, hash them in storage, avoid user enumeration, hash passwords correctly, and revoke sessions after reset.
If those pieces are in place, the flow is safe to ship for most MVP and small SaaS deployments.