Implementing a secure Python password reset via email is a must‑have feature for any modern web application. Not only does it improve user experience, but it also protects your platform from unauthorized access. In this tutorial we’ll walk through the entire process—from generating a reset token to sending the email and finally updating the user’s password—using plain Python, Flask, and the smtplib library. By the end of the article you’ll have a production‑ready password‑reset flow that you can drop into any project.
What Makes a Good Password‑Reset System?
- Security first: Use cryptographically strong tokens that expire after a short period.
- Usability: The email should contain a clear call‑to‑action and a single clickable link.
- Reliability: Handle bounced emails and rate‑limit requests to prevent abuse.
- Scalability: Store tokens in a way that works for both small apps and large deployments.
High‑Level Workflow
- User clicks “Forgot password?” on the login page.
- App asks for the user’s email address.
- Server generates a signed, time‑limited token.
- Email containing a password‑reset link (with the token) is sent to the user.
- User clicks the link, which opens a secure reset form.
- Server validates the token, updates the password, and invalidates the token.
Setting Up the Development Environment
Before we dive into code, make sure you have the following installed:
- Python 3.9+ (download here)
- Flask (
pip install flask) - itsdangerous (comes with Flask, used for token signing)
- An SMTP service (Gmail, SendGrid, Mailgun, etc.)
Project Structure
password_reset_demo/
├── app.py
├── config.py
├── templates/
│ ├── forgot_password.html
│ ├── reset_password.html
│ └── email_template.html
└── requirements.txt
Creating a Secure Token with itsdangerous
The itsdangerous library lets us create a signed token that includes the user’s email and an expiration timestamp. This token can be safely embedded in a URL.
# config.py
import os
class Config:
SECRET_KEY = os.getenv('SECRET_KEY', 'a-very-secret-key')
SECURITY_PASSWORD_SALT = os.getenv('SECURITY_PASSWORD_SALT', 'another-secret')
MAIL_SERVER = 'smtp.gmail.com'
MAIL_PORT = 587
MAIL_USE_TLS = True
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = MAIL_USERNAME
# utils.py
from itsdangerous import URLSafeTimedSerializer
from flask import current_app
def generate_reset_token(email):
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
return serializer.dumps(email, salt=current_app.config['SECURITY_PASSWORD_SALT'])
def verify_reset_token(token, expiration=3600):
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
try:
email = serializer.loads(
token,
salt=current_app.config['SECURITY_PASSWORD_SALT'],
max_age=expiration
)
except Exception:
return None
return email
Sending the Reset Email
We’ll use Python’s built‑in smtplib for simplicity, but you can replace it with Flask‑Mail or any third‑party service.
# mailer.py
import smtplib
from email.mime.text import MIMEText
from flask import url_for, render_template, current_app
def send_reset_email(to_email, token):
reset_url = url_for('reset_password', token=token, _external=True)
html_body = render_template('email_template.html', reset_url=reset_url)
msg = MIMEText(html_body, 'html')
msg['Subject'] = 'Your Password Reset Link'
msg['From'] = current_app.config['MAIL_DEFAULT_SENDER']
msg['To'] = to_email
with smtplib.SMTP(current_app.config['MAIL_SERVER'],
current_app.config['MAIL_PORT']) as server:
server.starttls()
server.login(current_app.config['MAIL_USERNAME'],
current_app.config['MAIL_PASSWORD'])
server.send_message(msg)
Flask Routes: From Request to Reset
# app.py
from flask import Flask, render_template, request, flash, redirect, url_for
from config import Config
from utils import generate_reset_token, verify_reset_token
from mailer import send_reset_email
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.config.from_object(Config)
# In‑memory user store for demo purposes
USERS = {
'alice@example.com': {
'password_hash': generate_password_hash('oldpassword')
}
}
@app.route('/forgot', methods=['GET', 'POST'])
def forgot_password():
if request.method == 'POST':
email = request.form['email'].strip().lower()
if email in USERS:
token = generate_reset_token(email)
send_reset_email(email, token)
flash('A password reset link has been sent to your email.', 'info')
else:
flash('If that address exists in our system, you will receive an email.', 'info')
return redirect(url_for('forgot_password'))
return render_template('forgot_password.html')
@app.route('/reset/', methods=['GET', 'POST'])
def reset_password(token):
email = verify_reset_token(token)
if not email:
flash('The reset link is invalid or has expired.', 'danger')
return redirect(url_for('forgot_password'))
if request.method == 'POST':
new_password = request.form['password']
USERS[email]['password_hash'] = generate_password_hash(new_password)
flash('Your password has been updated!', 'success')
return redirect(url_for('login')) # Assume a login route exists
return render_template('reset_password.html', token=token)
if __name__ == '__main__':
app.run(debug=True)
HTML Templates (Simplified)
<form method="post">
<label for="email">Enter your email:</label>
<input type="email" name="email" id="email" required>
<button type="submit">Send Reset Link</button>
</form>
<form method="post">
<label for="password">New password:</label>
<input type="password" name="password" id="password" required>
<button type="submit">Update Password</button>
</form>
<h3>Password Reset Request</h3>
<p>Hi,</p>
<p>We received a request to reset your password. Click the button below to choose a new password. This link will expire in 1 hour.</p>
<a href="{{ reset_url }}" style="display:inline-block;padding:10px 20px;background:#0069d9;color:#fff;text-decoration:none;border-radius:5px;">Reset My Password</a>
<p>If you didn’t request this, you can safely ignore this email.</p>
Best Practices & Security Tips
- Never store plain‑text passwords. Always hash with
werkzeug.security.generate_password_hashorbcrypt. - Use HTTPS. The reset link must be served over TLS to prevent token sniffing.
- Rate limit requests. Limit “forgot password” attempts per IP/email to mitigate brute‑force attacks.
- Invalidate old tokens. Store a token identifier in the database and mark it used after a successful reset.
- Short token lifespan. 30‑60 minutes is a good balance between usability and security.
Testing the Flow Locally
- Set environment variables for
MAIL_USERNAMEandMAIL_PASSWORD. - Run
python app.pyand navigate tohttp://127.0.0.1:5000/forgot. - Enter a registered email (e.g.,
alice@example.com) and check your inbox for the reset link. - Click the link, set a new password, and verify that the hash in the
USERSdict changes.
Deploying to Production
When you move from a development server to production, keep these items in mind:
- Replace the in‑memory
USERSstore with a real database (PostgreSQL, MySQL, etc.). - Use a robust email service (SendGrid, Amazon SES) to handle deliverability and bounce tracking.
- Store
SECRET_KEYandSECURITY_PASSWORD_SALTas environment variables, never in source control. - Enable Flask’s
SESSION_COOKIE_SECUREandCS
Leave a Reply