Python Password Reset Via Email Tutorial

Written by

in

Ever struggled to let users regain access to their accounts without compromising security? A reliable password‑reset flow is a must‑have feature for any modern web application, and Python makes it surprisingly straightforward. In this tutorial we’ll walk through every step required to build a secure, email‑based password reset system—from generating a time‑limited token to sending the reset link via SMTP and finally updating the user’s password safely. Whether you’re using Flask, Django, or a lightweight script, the concepts remain the same, and you’ll walk away with production‑ready code you can drop into your next project.

Why a Proper Password Reset Matters

Implementing a password reset isn’t just about convenience; it’s a critical security control. A well‑designed flow prevents:

  • Account takeover: Attackers can’t guess or reuse old passwords.
  • Phishing exploits: Tokens are short‑lived and bound to a specific user.
  • Brute‑force attacks: Rate limiting and token expiration stop automated attempts.

By following best practices—such as using cryptographically strong tokens, HTTPS, and secure hashing—you protect both your users and your brand reputation.

High‑Level Overview of the Process

  1. Request Reset: User submits their email address.
  2. Generate Token: Server creates a signed, time‑limited token.
  3. Send Email: An email containing a reset URL is dispatched via SMTP.
  4. Validate Token: When the user clicks the link, the token is verified.
  5. Update Password: User enters a new password, which is hashed and stored.

Setting Up the Environment

Required Packages

For this tutorial we’ll use Flask as the web framework, itsdangerous for token handling, and Flask-Mail (or smtplib for a pure‑Python approach). Install them with:

pip install Flask itsdangerous Flask-Mail python-dotenv

We also recommend python-dotenv to keep secret keys out of source control.

Project Structure

.
├── app.py
├── config.py
├── templates
│   ├── reset_request.html
│   ├── reset_password.html
│   └── email_reset.html
└── .env

Step‑by‑Step Implementation

1. Configure Flask and Mail Settings

# config.py
import os
from dotenv import load_dotenv

load_dotenv()  # Loads variables from .env

class Config:
    SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key')
    SECURITY_PASSWORD_SALT = os.getenv('SECURITY_PASSWORD_SALT', 'dev-salt')
    # SMTP configuration
    MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.gmail.com')
    MAIL_PORT = int(os.getenv('MAIL_PORT', 587))
    MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true'
    MAIL_USERNAME = os.getenv('MAIL_USERNAME')
    MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
    MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')

2. Initialize Flask, Mail, and Token Serializer

# app.py
from flask import Flask, render_template, request, flash, redirect, url_for
from flask_mail import Mail, Message
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from config import Config

app = Flask(__name__)
app.config.from_object(Config)

mail = Mail(app)
serializer = URLSafeTimedSerializer(app.config['SECRET_KEY'])

3. Create the Reset Request Form

The user supplies their email address. If the address exists in the database, we generate a token and send the email.

@app.route('/reset', methods=['GET', 'POST'])
def reset_request():
    if request.method == 'POST':
        email = request.form['email']
        # TODO: Replace with real DB lookup
        user = get_user_by_email(email)
        if user:
            token = serializer.dumps(email, salt=app.config['SECURITY_PASSWORD_SALT'])
            reset_url = url_for('reset_token', token=token, _external=True)
            send_reset_email(user.email, reset_url)
            flash('A password reset link has been sent to your email.', 'info')
            return redirect(url_for('login'))
        else:
            flash('Email address not found.', 'danger')
    return render_template('reset_request.html')

4. Sending the Reset Email

def send_reset_email(to_email, reset_url):
    subject = "Your Password Reset Link"
    html_body = render_template('email_reset.html', reset_url=reset_url)
    msg = Message(subject=subject, recipients=[to_email], html=html_body)
    mail.send(msg)

5. Build the Email Template

<!-- templates/email_reset.html -->
<p>Hello,</p>
<p>You requested a password reset. Click the link below to set a new password. This link will expire in 30 minutes.</p>
<p><a href="{{ reset_url }}">Reset My Password</a></p>
<p>If you didn’t request this, please ignore this email.</p>
<p>Thanks,<br>Your Application Team</p>

6. Validate the Token and Show the New Password Form

@app.route('/reset/<token>', methods=['GET', 'POST'])
def reset_token(token):
    try:
        email = serializer.loads(
            token,
            salt=app.config['SECURITY_PASSWORD_SALT'],
            max_age=1800  # 30 minutes
        )
    except SignatureExpired:
        flash('The reset link has expired.', 'danger')
        return redirect(url_for('reset_request'))
    except BadSignature:
        flash('Invalid reset token.', 'danger')
        return redirect(url_for('reset_request'))

    if request.method == 'POST':
        password = request.form['password']
        confirm = request.form['confirm']
        if password != confirm:
            flash('Passwords do not match.', 'danger')
            return render_template('reset_password.html')
        # TODO: Hash password and update DB
        update_user_password(email, password)
        flash('Your password has been updated. You can now log in.', 'success')
        return redirect(url_for('login'))

    return render_template('reset_password.html', token=token)

7. Secure Password Storage

Never store plain‑text passwords. Use werkzeug.security.generate_password_hash (or bcrypt) to hash the new password before saving.

from werkzeug.security import generate_password_hash

def update_user_password(email, raw_password):
    hashed = generate_password_hash(raw_password)
    # Replace with actual DB update logic
    user = get_user_by_email(email)
    user.password_hash = hashed
    db.session.commit()

8. Adding Rate Limiting (Optional but Recommended)

To stop abuse, integrate Flask-Limiter or implement a simple counter in your database that tracks how many reset requests a user makes within a given timeframe.

Testing the Flow Locally

  1. Set up a .env file with your SMTP credentials (e.g., Gmail app password).
  2. Run flask run and navigate to /reset.
  3. Enter a registered email address; you should receive a reset link.
  4. Click the link, change the password, and verify you can log in with the new credentials.

If you’re using Gmail, remember to enable “Less secure app access” or, better yet, create an App Password for added security.

Deploying to Production

Key Checklist

  • HTTPS Only: Force SSL/TLS to protect token leakage.
  • Environment Secrets: Store SECRET_KEY, SECURITY_PASSWORD_SALT, and mail credentials in a secrets manager (AWS Secrets Manager, Docker secrets, etc.).
  • Token Expiration: Keep the window short (15‑30 minutes) to limit exposure.
  • Audit Logging: Record reset attempts and successes for compliance.
  • CAPTCHA: Add a CAPTCHA challenge on the reset request form to deter bots.

Example Production Settings

# .env (do NOT commit!)
SECRET_KEY=super‑strong‑random‑bytes‑base64
SECURITY_PASSWORD_SALT=another‑random‑string
MAIL_SERVER=smtp.sendgrid.net
MAIL_PORT=587
MAIL_USE_TLS=true
MAIL_USERNAME=apikey
MAIL_PASSWORD=SG.xxxxxxx  # SendGrid API key
MAIL_DEFAULT_SENDER=no-reply@yourdomain.com

Common Pitfalls and How to Avoid Them

  • Token Reuse: Always generate a fresh token per request; never store the token in the database.
  • Plain‑Text Links in Logs: Mask the reset URL when logging to avoid accidental exposure.
  • Weak Password Policies: Enforce minimum length, complexity, and disallow common passwords.
  • Missing CSRF Protection: Use Flask‑WTF or Django’s built‑in CSRF middleware on all forms.
  • Unverified Email Addresses: Ensure

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *