DEV Community

Cover image for How Do I Send a Password Reset Email from a Backend API?
sohom das
sohom das

Posted on

How Do I Send a Password Reset Email from a Backend API?

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

  1. Frontend calls POST /auth/forgot-password with the user's email
  2. Backend looks up the user — if not found, proceed anyway without revealing that
  3. Generate a random, unguessable token; store only its hash, with an expiry and a "used" flag
  4. Build a reset link containing the raw token
  5. Call the email provider's send endpoint with the link
  6. Return a generic success response regardless of whether the account existed
  7. 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}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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>
"""
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)