You wire a "forgot password" endpoint to a transactional email provider: generate a secure token, store its hash, email a reset link, then validate the token on a second endpoint when the user comes back. One correction before I get into it, since I've seen this repeated inaccurately elsewhere: not every provider in this space has an SDK — Notify specifically doesn't. It's plain HTTP, which turns out to work identically well in Python as it does anywhere else, so that's what I'll use here to show the whole flow isn't tied to any one language or framework.
The High-Level Flow
- Frontend calls
POST /auth/forgot-passwordwith the user's email - Backend looks up the user — if not found, proceed anyway without revealing that
- Generate a random, unguessable token; store only its hash, with an expiry and a "used" flag
- Build a reset link containing the raw token
- Call the email provider's send endpoint with the link
- Return a generic success response regardless of whether the account existed
- User visits the link, submits a new password; backend validates the token, updates the password, invalidates the token, and optionally revokes existing sessions
The Forgot-Password Endpoint
import secrets
import hashlib
from datetime import datetime, timedelta
def generate_secure_token():
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def forgot_password(request):
email = request.json["email"]
user = await db.users.get_by_email(email)
if not user:
# Don't reveal whether the email exists
return {"ok": True}
token = generate_secure_token()
await db.reset_tokens.create(
user_id=user.id,
token_hash=hash_token(token),
expires_at=datetime.utcnow() + timedelta(minutes=30),
used=False,
)
reset_link = f"https://app.example.com/reset-password?token={token}"
await send_reset_email(user.email, reset_link)
return {"ok": True}
Calling Notify from Python
This is the part where "no SDK" actually matters in practice — no package to install, just a standard HTTP request:
import requests
import os
def send_reset_email(to_email, reset_link):
response = requests.post(
"https://notify.cx/api/email/send",
headers={
"Content-Type": "application/json",
"x-api-key": os.environ["NOTIFY_API_KEY"],
},
json={
"from": "noreply@your-verified-domain.com",
"to": to_email,
"subject": "Reset your password",
"message": (
f"<p>Click below to reset your password. This link expires in 30 minutes.</p>"
f'<p><a href="{reset_link}">Reset password</a></p>'
),
},
)
response.raise_for_status()
return response.json()
Same to/from/subject/message shape whether you're calling this from Python, Node, Ruby, or curl directly — there's no client library abstracting it differently per language, because there isn't a client library at all.
The Reset Endpoint
async def reset_password(request):
token = request.json["token"]
new_password = request.json["password"]
record = await db.reset_tokens.get_by_token_hash(hash_token(token))
if not record or record.used or record.expires_at < datetime.utcnow():
raise Error("Invalid or expired reset token")
user = await db.users.get(record.user_id)
await db.users.update_password(user.id, hash_password(new_password))
await db.reset_tokens.mark_used(record.id)
await db.sessions.revoke_all(user.id)
return {"ok": True}
Revoking existing sessions after a successful reset matters more than it might seem — if an attacker was already logged in with a compromised password, a reset alone doesn't kick them out unless you explicitly invalidate their sessions too.
Security and Deliverability Practices
| Category | Practice |
|---|---|
| Token | Unguessable (32+ random bytes), single-use, 15–60 minute expiry, hashed at rest |
| Endpoint behavior | Rate-limit per email and per IP; always return a generic success response |
| Email content | Clear subject line, both HTML and plain-text parts, one clear link, optionally IP/browser info for security awareness |
| Infrastructure | Verified domain with SPF/DKIM/DMARC; consider a dedicated subdomain (auth.yourdomain.com) to isolate reputation from any marketing mail; use a transactional-only provider so resets aren't queued behind bulk sends |
That last infrastructure point is where a tool like Notify has a structural advantage worth naming: there's no bulk-sending feature in the product at all, so there's no way for a password reset to end up queued behind or affected by marketing volume, even accidentally. Domain verification itself is the standard three DNS records regardless of provider.
Including Security Context in the Email
The source pattern above mentions showing IP or browser info in the reset email for security awareness — worth building into your render_password_reset_email-equivalent function, since it gives the recipient a way to notice "that wasn't me" if the request wasn't theirs:
message = f"""
<p>A password reset was requested from {request.client_ip}.</p>
<p>Click below to reset your password. This link expires in 30 minutes.</p>
<p><a href="{reset_link}">Reset password</a></p>
<p>If you didn't request this, you can safely ignore this email.</p>
"""
Rate-Limiting the Endpoint
An unlimited forgot-password endpoint is an easy way to spam a specific inbox or probe which addresses exist based on response timing, even with the generic-response protection above. A simple per-email and per-IP limit, checked before any token generation happens:
from datetime import datetime, timedelta
async def check_rate_limit(email: str, ip: str) -> bool:
recent_attempts = await db.reset_attempts.count(
email=email,
since=datetime.utcnow() - timedelta(hours=1)
)
return recent_attempts < 5
async def forgot_password(request):
email = request.json["email"]
ip = request.client_ip
if not await check_rate_limit(email, ip):
return {"ok": True} # Same generic response, even when rate-limited
user = await db.users.get_by_email(email)
await db.reset_attempts.log(email=email, ip=ip)
if not user:
return {"ok": True}
# ...rest of the flow
Notice the rate-limited response is identical to every other response this endpoint returns — a different status code or message here would leak information about which requests actually triggered a send, undoing the enumeration protection you built everywhere else.
Handling a Failed Send Gracefully
Worth deciding upfront what happens if the call to Notify fails — a network blip, a temporary outage, whatever. Since the user-facing response is already generic and returned regardless of outcome, the failure needs to be handled asynchronously rather than surfaced to the request:
async def send_reset_email_safely(to_email, reset_link):
try:
return send_reset_email(to_email, reset_link)
except requests.exceptions.RequestException as e:
logger.error(f"Failed to send reset email to {to_email}: {e}")
# Consider a retry queue here rather than silently dropping it
raise
I'd queue this rather than call it synchronously inline in the request handler in anything beyond a small side project — a slow or failed email send shouldn't hold up the HTTP response the frontend is waiting on.
Where This Fits Into a Bigger Auth Email Set
Password reset is usually the first of several auth-related emails a real product ends up sending — email verification, magic links, new-device login alerts, and this one all follow the same token-hash-and-send shape, just with different expiry windows and copy. If you're building more than just this one flow, Notify's own auth email guide covers the reset case in more depth, and their broader rundown of transactional emails a SaaS typically needs is worth a look before you build each one from scratch individually. I've found the free tier is enough to build and test the whole reset flow before paying for anything.
Frequently Asked Questions
How do I send a password reset email from a backend API?
Generate a random, single-use token, store its hash with an expiry, build a reset link, and call your email provider's send endpoint. With Notify, that's a POST to https://notify.cx/api/email/send with an x-api-key header — no SDK required in any language. Validate the token on a separate endpoint when the user submits their new password, then invalidate it and revoke existing sessions.
Does Notify have an SDK for Python, Node.js, or other languages?
No — despite what you might read elsewhere, Notify has no official SDK in any language. It's a plain HTTP API, which works consistently in Python, Node, Ruby, or any language with an HTTP client.
Should I reveal whether an email address exists in my system?
No — always return the same generic success response regardless of whether the account exists. This is standard practice to prevent account enumeration.
How long should a password reset token remain valid?
15 to 60 minutes is typical, with the token marked as used (or deleted) immediately after a successful reset.
Should I revoke a user's existing sessions after a password reset?
Yes — this is a security practice worth including, not an optional extra. If an account was compromised, a password change alone doesn't end an attacker's existing session unless you explicitly revoke it.
Why does using a transactional-only provider matter for password reset emails specifically?
It removes any risk of a reset email being delayed behind or associated with marketing sends, since there's no bulk-sending feature to blur the two. This is a structural property of tools like Notify rather than something you have to configure correctly each time.
Should I send the password reset email synchronously inside the request handler?
For a small side project, it's fine. For anything with real traffic, queue it instead — a slow or failed call to your email provider shouldn't hold up the HTTP response your frontend is waiting on, and a queue gives you a natural place to retry a failed send.
What should the reset email include besides the link itself?
A clear subject line, an explicit expiry time, and optionally the requesting IP address or browser, so the recipient has a way to notice if the request wasn't actually theirs. Both HTML and plain-text versions are worth including for clients that don't render HTML.
Top comments (0)