13.14. Password Reset
Table of Contents
Why Password Reset Matters
Almost every real application needs a way for users to recover access to their accounts. People forget passwords, accounts get locked, emails change. Without a secure password reset flow, users get stuck or, worse, attackers can easily take over accounts.
So you must design a password reset system that is:
- Easy to use for real users.
- Hard to abuse for attackers.
- Safe if an email or link is leaked.
In this chapter we focus on the mechanics and security of password reset. We will not re-explain password hashing and storage, which are covered in the Password Hashing chapter. Here we assume you already store passwords securely, for example with bcrypt or Argon2.
Typical Password Reset Flow
Most sites follow a similar flow. Understanding this helps you design and implement your own.
- User clicks “Forgot password?”
- User enters their email (or username).
- Backend checks that account exists, and if so, creates a one-time reset token.
- Backend sends an email with a link that contains the token.
- User clicks the link, backend verifies the token and shows a “Set new password” form.
- User submits a new password.
- Backend verifies the token again, sets the new password, invalidates the token, and logs the user in or redirects to login.
You can think of the reset token as a temporary permission to change a specific account’s password.
Password Reset Token Design
What is a Reset Token?
A password reset token is a secret value that proves:
- Which user is allowed to reset.
- That the reset is still valid in time.
- That the reset request was created by your server.
You never ask the user to type the token manually. It is embedded in a URL sent by email.
Example reset link:
https://example.com/reset-password?token=5aXszT0L8t0R4d0mkT2Y7wThe token must be:
- Random so attackers cannot guess it.
- Long enough to make brute-force guessing impossible.
- Single-use so after one successful reset it cannot be used again.
- Short-lived so an old email cannot be abused.
Important rule: A password reset token must be treated like a password.
Never log it in plaintext, never expose how you generate it, and never reuse it.
Token Length and Randomness
Use a cryptographically secure random generator, not a normal random function.
In Python:
import secrets
token = secrets.token_urlsafe(32)This produces a URL safe string with good entropy.
Approximate entropy:
- 16 bytes: about 128 bits
- 32 bytes: about 256 bits
128 bits is already extremely strong. 32 bytes is commonly used and very safe.
Why Use URL Safe Tokens?
Some characters are special in URLs. token_urlsafe uses only characters that are safe to put in a query string without extra encoding. This makes links easier to click and handle.
Stateless vs Stateful Reset Tokens
There are two main approaches to token design. Both are used in real applications.
Stateful Tokens (Database Stored)
With a stateful token, you:
- Generate a random token.
- Store it (or its hash) in your database with:
- The user id it belongs to.
- Expiration time.
- Whether it was used already.
- Send the raw token in the email.
- When the user clicks the link, you look up the token in the database.
Table Example
You could have a table like:
| Column | Type | Description |
|---|---|---|
| id | UUID / integer | Internal id |
| user_id | UUID / integer | Which user this token belongs to |
| token_hash | string | Hash of the token (not token itself) |
| expires_at | datetime | When token becomes invalid |
| used_at | datetime / null | When it was used |
| created_at | datetime | Audit info |
| ip_address | string / null | IP that requested the reset (optional) |
| user_agent | string / null | Client info (optional) |
Notice we store token_hash instead of the raw token. That way if your database is compromised, attackers do not get instant reset tokens.
Example in Python:
import secrets
import hashlib
from datetime import datetime, timedelta
def create_reset_token(user_id, db_session):
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.utcnow() + timedelta(hours=1)
db_session.execute(
"""
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES (:user_id, :token_hash, :expires_at)
""",
{"user_id": user_id, "token_hash": token_hash, "expires_at": expires_at},
)
db_session.commit()
return raw_tokenTo verify:
def verify_reset_token(raw_token, db_session):
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
row = db_session.execute(
"""
SELECT * FROM password_reset_tokens
WHERE token_hash = :token_hash
""",
{"token_hash": token_hash},
).fetchone()
if row is None:
return None # invalid token
if row.used_at is not None:
return None # already used
if row.expires_at < datetime.utcnow():
return None # expired
return row.user_idPros and Cons
| Approach | Pros | Cons |
|---|---|---|
| Stateful tokens | Easy to revoke, invalidate, audit | Needs database reads and writes |
Stateful tokens are common and relatively easy to implement.
Stateless Tokens (Signed Data, like JWT)
With stateless tokens, all data is encoded and cryptographically signed. The server does not have to store each token.
Example contents:
{
"sub": "user-id-123",
"type": "password_reset",
"exp": 1712345678
}You sign it with a secret key and send it as a compact string. On verification, you:
- Verify the signature to ensure the token was created by your server.
- Check
expto ensure it is not expired. - Check
typeequals"password_reset".
This approach is similar to JSON Web Tokens, but you can also design a custom format.
Pros and Cons
| Approach | Pros | Cons |
|---|---|---|
| Stateless tokens | No DB lookup needed for each verification | Revocation is harder, needs careful design |
For many applications, stateful tokens are simpler and safer to start with. Stateless tokens are more advanced and must be designed carefully if you need revocation.
Token Lifetime and Expiration
If tokens live too long, an attacker who gains access to an old email can take over the account. If they are too short, real users may be frustrated.
Common lifetimes:
| Lifetime | Use case |
|---|---|
| 15 minutes | Very security sensitive apps |
| 1 hour | Most web apps, good balance |
| 24 hours | Very user friendly, but more risk |
Rule: Always store an explicit expiration time, and always check it before accepting a token.
You can store expires_at in the database for stateful tokens, or use exp for stateless tokens.
Token Reuse and Invalidation
A reset token should be single-use. After a successful reset, you must ensure it cannot be used again.
For stateful tokens:
- After verifying token:
- Mark
used_atwith current time. - Or delete the row from the table.
- Make sure future checks verify
used_at IS NULL.
For stateless tokens, you need a strategy, for example:
- Short expiration times.
- A server-side "reset counter" or "token version" stored in the user record, and included in the token.
Example idea:
- User table has column
password_reset_versioninteger. - When you create a token, include that value inside the token.
- On reset, increment
password_reset_version. - Any old token will have a mismatched version and be rejected.
Creating the Reset Request Endpoint
Endpoint Design
Typically:
- Method:
POST - Path:
/auth/forgot-passwordor/password-reset/request
Request body:
{
"email": "user@example.com"
}Response:
- Do not reveal whether the email exists.
{
"message": "If an account with that email exists, we have sent a password reset link."
}Why Hide the Existence of Accounts?
If you return different responses for existing and non-existing emails:
- Attackers can use the endpoint to enumerate valid emails in your system.
Protecting this information is a basic security practice.
Sending the Reset Email
Email Content
A simple reset email might look like:
Subject: Reset your password
Hi Alice,
We received a request to reset your password for your ExampleApp account.
Click this link to choose a new password:
https://example.com/reset-password?token=5aXszT0L8t0R4d0mkT2Y7w
This link will expire in 1 hour.
If you did not request this, you can safely ignore this email.
ExampleApp Security Team
Key elements:
- Clear that this is a password reset.
- A full link with the token.
- Expiration information.
- A note that if they did not request it, they can ignore it.
Do Not Include Passwords in Email
Never send:
- Plaintext passwords.
- Temporary passwords created by the server that the user must use.
Always let the user choose a new password through a secure form.
Reset Form Endpoint
When the user clicks the link, they visit something like:
GET /reset-password?token=XYZYour frontend:
- Extracts the
tokenfrom the query string. - Stores it in memory (for example in JavaScript state, not in local storage if you can avoid it).
- Shows a form:
New password: [.................]
Confirm password: [.............]
[Submit]On submit the frontend sends:
POST /reset-password
Content-Type: application/json
{
"token": "XYZ",
"new_password": "NewSecure#Password1"
}Your backend will:
- Validate the body (presence of token and new password).
- Verify that the token is valid.
- Validate password strength.
- Hash the new password.
- Update the user record.
- Invalidate the token.
- Return success.
Integrating with Password Hashing
You already learned how to hash passwords when users register or change their passwords normally. The reset flow must use the same hashing function.
Example in Python with bcrypt using passlib:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)In the reset endpoint:
def reset_password(token: str, new_password: str, db_session):
user_id = verify_reset_token(token, db_session)
if user_id is None:
raise InvalidTokenError()
password_hash = hash_password(new_password)
db_session.execute(
"UPDATE users SET password_hash = :password_hash WHERE id = :user_id",
{"password_hash": password_hash, "user_id": user_id},
)
# Invalidate token
mark_token_used(token, db_session)
db_session.commit()Make sure:
- You never store the new password in plaintext.
- You do not log the new password.
- You do not send the new password back in the API response.
Password Validation and Security
Password Strength Rules
You should define password rules that are not too weak but also not impossible to use. Examples:
- Minimum length, for example 8 or 10 characters.
- Require at least one letter and one digit.
- Optionally require a special character.
Example simple rule:
Example rule:
A valid password must be at least 8 characters long and contain at least one letter and one digit.
You can implement this with regular expressions or manual checks.
In Python:
import re
def is_valid_password(pw: str) -> bool:
if len(pw) < 8:
return False
if not re.search(r"[A-Za-z]", pw):
return False
if not re.search(r"[0-9]", pw):
return False
return TrueYou should perform the same validation on:
- Registration.
- Normal password change.
- Password reset.
Prevent Reuse of Old Passwords (Optional)
Some systems require that the new password is not equal to the old one. You can:
- Compare the new password with the old hash:
- Naive attempt (incorrect):
if new_password == old_password_hash: # WRONG, they cannot be equal
...- Correct concept:
- Hash the new password and compare with the stored hash is not meaningful, because each hashing call generates a new salt, so hashes will be different even for the same password.
- Instead, you check:
if verify_password(new_password, old_password_hash):
# same password as before- Or maintain a history of password hashes and reject if the new password matches any of the last N hashes.
This is more advanced, and many simple apps do not implement password history. Decide based on your security needs.
Security Protections Around Password Reset
Password reset is a powerful feature, so it is a common target for attackers. You must protect it against abuse.
Rate Limiting Reset Requests
Attackers might:
- Try to spam reset emails to many users.
- Use your reset form to send spam, if they can control email content partially.
- Use the endpoint to cause denial of service.
You can mitigate with rate limiting:
- Limit how many reset emails you send to one email address per time period.
- Limit how many reset requests any single IP address can perform per minute.
Example policies:
| Target | Limit example |
|---|---|
| Per email | 3 reset emails per hour |
| Per IP address | 10 reset requests per 10 minutes |
If the limit is hit, you can still respond with the generic message:
"If an account with that email exists, we have sent a password reset link."
But internally you do not send another email.
Throttling Token Verification
While verifying tokens, you should:
- Reject obviously invalid tokens quickly.
- Optionally add small delays when tokens are wrong to slow down brute-force attempts.
Example:
import time
def verify_reset_token_with_delay(token: str, db_session):
user_id = verify_reset_token(token, db_session)
if user_id is None:
time.sleep(0.5) # Add small delay
return user_idBe careful not to introduce big delays that can be used for DoS, but small, consistent delays can make brute-force less effective.
Secure Transport: Always Use HTTPS
Reset links contain secrets. If you send them over plain HTTP:
- Anyone on the same network can see them.
- Attackers can capture and reuse them.
You must:
- Use HTTPS for all pages that handle tokens.
- Ensure your email links start with
https://, nothttp://.
Avoid Leaking Tokens in Logs and Referrers
If the reset token is in the query string, for example:
https://example.com/reset-password?token=XYZIt might appear in:
- Web server logs.
- Browser history.
- Third party analytics referrer headers if you link out from that page.
To reduce risk:
- Avoid sending the token to other sites, for example do not include external images or scripts that might receive the
Refererheader. - Consider moving the token from query string into a POST body as soon as possible.
A common approach:
- Frontend reads the query string token.
- Immediately POSTs to your backend to exchange it for a temporary reset session or just stores it in memory.
- After that, the frontend navigates to a new URL without the token.
Example: End-to-End Flow (Simplified)
To make it concrete, here is a simple text sequence of the whole flow using a stateful token.
- User opens login page and clicks "Forgot password?"
- Frontend shows form:
POST /auth/forgot-password
Content-Type: application/json
{
"email": "user@example.com"
}- Backend:
- Looks up
user@example.com. If user exists: - Generates random token
"XYZ". - Stores
hash("XYZ")inpassword_reset_tokenswith user_id, expires_at. - Sends email with link
https://app.example.com/reset-password?token=XYZ. - Returns generic response.
- User opens email, clicks link.
- Browser hits:
GET /reset-password?token=XYZ- Frontend:
- Reads
token=XYZfrom URL. - Shows form to enter new password.
- User submits new password:
POST /reset-password
Content-Type: application/json
{
"token": "XYZ",
"new_password": "NewSecure#Password1"
}- Backend:
- Looks up token hash.
- Verifies not expired and not used.
- Validates password strength.
- Hashes the new password.
- Updates the user record in
userstable. - Marks token as used or deletes it.
- Responds with success.
- Optional:
- Invalidate all existing sessions for that user.
- Send an email: "Your password was changed. If this was not you, contact support."
Extra Safety Measures
As your applications grow more important, you may want additional protections.
Notify on Password Reset Requests
When someone starts a password reset for an account, you can notify the user:
- When reset is requested.
- When password is successfully reset.
Example email for reset requested:
We received a request to reset the password for your account.
If you did not request this, someone may be trying to access your account.
If you are concerned, contact support.
Even if an attacker triggers these, the user learns that something is happening.
Invalidating Existing Sessions After Reset
If an attacker had access to a user's session but not the email, and the user resets the password, you want to kick the attacker out.
You can:
- Maintain a
password_changed_attimestamp in the user table. - Store this timestamp in each session or token at login time.
- At each request, check that
session.password_changed_at == user.password_changed_at.
When a password is reset:
- Update
password_changed_atto now. - All old sessions that have the old timestamp become invalid.
This is more advanced but very effective.
Common Mistakes to Avoid
Here are some mistakes you must avoid when building password reset.
| Mistake | Why it is dangerous |
|---|---|
| Using short, guessable tokens | Allows brute-force attacks |
| Storing tokens in plaintext in the database | DB leak equals full account takeover |
| No expiration time on tokens | Old emails can reset accounts years later |
| Allowing token reuse | If someone sees the link, they can reuse it |
| Revealing whether an email exists | Enables account enumeration |
| Sending new passwords via email | Email is not a safe channel for passwords |
| Logging tokens or passwords | Logs often have wide access |
| Using HTTP instead of HTTPS | Tokens can be intercepted on the network |
Try to avoid all of these in your own implementation.
Summary
In password reset, you give users a safe way to recover accounts, but you also open a powerful entry point into your system. The key ideas are:
- Use strong, random, single-use tokens.
- Store token hashes server side or use properly signed stateless tokens.
- Always set and check expiration times.
- Hide whether an email exists in your system.
- Validate new passwords and hash them using secure algorithms.
- Protect endpoints with rate limiting and HTTPS.
- Invalidate tokens and, ideally, sessions after a successful reset.
If you follow these guidelines, your password reset feature will be both user friendly and secure, and will fit cleanly into the broader authentication system you are building.
Views: 7
KAHIBARO