Password Reset Flow
Password Reset Flow
Password reset is one of the trickier features to implement correctly. It touches security, email, time-limited tokens, and database state all at once. This tutorial builds the complete flow.
How It Works
- User submits their email on a "Forgot Password" form
- App generates a secure token and stores it with an expiry timestamp
- App emails a link containing the token
- User clicks the link, submits a new password
- App validates the token (exists? not expired? not used?), updates the password, marks token used
Database Setup
Add a password_reset_tokens table:
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(100) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_reset_tokens_token ON password_reset_tokens(token);
Step 1: Forgot Password Form and Route
import secrets
from datetime import datetime, timedelta, timezone
from utils.email import send_email
from flask import render_template_string
@app.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
if request.method == "GET":
return render_template("forgot_password.html")
email = request.form.get("email", "").strip().lower()
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, name FROM users WHERE email = %s", (email,))
user = cur.fetchone()
# Always show the same message — do not reveal if email exists
if user:
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO password_reset_tokens (user_id, token, expires_at)
VALUES (%s, %s, %s)
""", (user["id"], token, expires_at))
conn.commit()
reset_url = f"{request.host_url}reset-password/{token}"
send_email(
to=email,
subject="Reset your password",
html=f"""
<p>Hi {user["name"]},</p>
<p>Click below to reset your password. This link expires in 1 hour.</p>
<a href="{reset_url}">{reset_url}</a>
<p>If you did not request this, ignore this email.</p>
"""
)
return render_template("forgot_password.html",
message="If that email is in our system, a reset link is on its way."
)
The "always show the same message" pattern prevents attackers from discovering which emails are registered.
Step 2: Reset Password Form and Route
from werkzeug.security import generate_password_hash
@app.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
# Look up the token
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT id, user_id, expires_at, used
FROM password_reset_tokens
WHERE token = %s
""", (token,))
reset = cur.fetchone()
# Validate token
if not reset:
return render_template("reset_password.html", error="Invalid or expired link.")
if reset["used"]:
return render_template("reset_password.html", error="This link has already been used.")
if reset["expires_at"].replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
return render_template("reset_password.html", error="This link has expired. Request a new one.")
if request.method == "GET":
return render_template("reset_password.html", token=token)
# POST: update the password
new_password = request.form.get("password", "")
if len(new_password) < 8:
return render_template("reset_password.html",
token=token,
error="Password must be at least 8 characters."
)
hashed = generate_password_hash(new_password)
with get_db() as conn:
with conn.cursor() as cur:
# Update password
cur.execute(
"UPDATE users SET password_hash = %s WHERE id = %s",
(hashed, reset["user_id"])
)
# Mark token as used
cur.execute(
"UPDATE password_reset_tokens SET used = TRUE WHERE id = %s",
(reset["id"],)
)
conn.commit()
return redirect("/login?message=Password+updated.+Please+log+in.")
Security Checklist
| Check | Why |
|---|---|
secrets.token_urlsafe(32) |
Cryptographically random — not guessable |
| 1-hour expiry | Limits the window if email is intercepted |
used flag |
Prevents replaying a token that was already used |
| Same message for all emails | Does not leak which emails are registered |
| Minimum password length | Basic protection against weak resets |
| Token in URL path, not query string | Slightly less likely to appear in server logs |
Cleaning Up Old Tokens
Add a periodic cleanup (run via a cron job or Celery beat):
def cleanup_expired_tokens():
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM password_reset_tokens
WHERE expires_at < NOW() - INTERVAL '7 days'
""")
conn.commit()
AI Prompt That Generated This
"Build a Flask password reset flow. Include: a forgot-password POST route that generates a secrets.token_urlsafe(32) token, stores it with a 1-hour expiry in password_reset_tokens table, and emails a reset link. A reset-password/
route that validates the token, checks expiry and used flag, updates the password with generate_password_hash, and marks the token used. Use psycopg2, no ORM."
Next Steps
- Set up Transactional Email with Resend to send the reset link
- Protect the forgot-password route with Rate Limiting to prevent abuse
Was this helpful?
Get More Flask Vibe Tutorials
Join 1,000+ developers getting weekly Flask tips and AI-friendly code patterns.
No spam. Unsubscribe anytime.