security intermediate

Password Reset Flow

min read Frederick Tubiermont

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

  1. User submits their email on a "Forgot Password" form
  2. App generates a secure token and stores it with an expiry timestamp
  3. App emails a link containing the token
  4. User clicks the link, submits a new password
  5. 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

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.