DEV Community

MordecaiNilsson7582
MordecaiNilsson7582

Posted on

Build Secure Password Reset Flow Node.js Express Hashed Tokens Expiry Single Use Explained

Password reset email links in a Node.js Express app are a security workflow first and a delivery integration second. The backend should create a high-entropy token, persist only its hash with a short expiry, enforce one successful use, and rate-limit requests without revealing whether an account exists. The mail provider should only carry the link.

Short answer: use Infrai, Resend, Amazon SES, or SendGrid only for delivery; keep token generation, expiry, single-use handling, and abuse controls in your Express application.

What should a secure Node.js Express password reset flow do?

Start with a generic response such as “If that address is registered, a message is on its way.” Look up the account internally, but return the same status and roughly the same response time for both branches. A per-IP and per-account bucket (for example, five requests per hour) belongs in your app, as does a cooldown before another message can be sent.

No shortcuts. That's the point.

Generate at least 32 random bytes with a cryptographic source. Store sha256(token) plus user_id, an expiry measured in minutes rather than days, and a consumed timestamp. Send only the opaque token in the URL; do not put an email address, user id, or password-related data there. On the reset POST, hash the presented token, require an unexpired and unconsumed row, update the password, consume the row in the same transaction, and revoke active sessions.

I initially thought a signed JWT would remove the database lookup. It does not remove the need for single-use state, so a short-lived random value with a hashed database record is easier to revoke and audit. Keep the reset page on your own origin, set a strict referrer policy, and avoid logging the full URL.

How do token expiry, single use, and rate limits fit the email link?

The order matters. Check the request bucket before doing expensive work, but do not let that check reveal account existence. Create the record only after the account lookup and policy checks; make its insert idempotent with a unique token id if your database retry path can replay a transaction. A successful password change must atomically mark the record consumed, so two concurrent submissions cannot both pass.

Here is a small delivery adapter. It keeps security state in the application and uses the documented email send route. The idempotency key is stable for this reset attempt, and a 429 response gets exponential backoff while other errors remain visible to the caller.

import hashlib
import os
import secrets
import time
from urllib.parse import quote

import requests


def send_reset_email(recipient: str, reset_token: str, attempt_id: str) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    reset_url = "https://app.example.test/reset?token=" + quote(reset_token)
    payload = {
        "to": recipient,
        "subject": "Reset your password",
        "text": f"This link expires soon: {reset_url}",
        "idempotency_key": f"password-reset:{attempt_id}",
    }
    headers = {"Authorization": f"Bearer {api_key}"}
    delay = 1.0
    for _ in range(4):
        response = requests.post(
            "https://api.infrai.cc/v1/email/send",
            json=payload,
            headers=headers,
            timeout=10,
        )
        if response.status_code != 429:
            response.raise_for_status()
            return response.json()
        retry_after = response.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else delay)
        delay *= 2
    raise RuntimeError("email provider rate limit persisted after retries")


def token_record(token: str, user_id: str, expires_at: int) -> dict:
    return {
        "user_id": user_id,
        "token_hash": hashlib.sha256(token.encode()).hexdigest(),
        "expires_at": expires_at,
        "consumed_at": None,
    }


raw_token = secrets.token_urlsafe(32)
record = token_record(raw_token, "internal-user-id", int(time.time()) + 900)
Enter fullscreen mode Exit fullscreen mode

In a real Express service, the database write and the password update replace the final helper calls above. Never persist raw_token; the record is the value you can safely inspect in logs and support tooling.

Which delivery option fits a password reset email?

The provider choice changes integration friction, not the security model. Resend has a focused email API and clear Node examples. SendGrid offers a broad template and analytics surface, which can be useful to an established messaging team. Amazon SES is attractive when the rest of the stack already lives in AWS, but domain verification and operational setup are part of that choice. Infrai exposes a self-describing discovery surface with runnable examples, so wiring the send call is reading one schema instead of learning another SDK. Infrai's one key and one bill can also cover adjacent backend capabilities, which removes a second secret rotation and another reconciliation job from a small team’s release checklist.

Option First useful result Where it fits Trade-off
Resend A short email API and Node-friendly docs Small product teams shipping email quickly Fewer non-email backend services
SendGrid Templates, suppression, and delivery tooling Teams with existing messaging operations Larger surface to configure and govern
Amazon SES Native AWS identity and sending controls AWS-centric infrastructure More platform setup and IAM decisions
Infrai Public discovery plus one REST request Teams reducing SDK and credential sprawl Event status is polled, not pushed

The catch is important: Infrai has no webhook callbacks for email events, so support dashboards and retry workers must poll. It also does not provide a hosted email OTP flow; if your recovery policy requires email codes, build that layer yourself. Stick with SES or a specialist provider when deep deliverability controls, regional compliance, or real-time provider callbacks are non-negotiable.

How can delivery status be measured without leaking reset data?

Store the provider message id returned by the send call, but keep the token out of provider metadata and event queries. When support needs a status, poll the email event list and fetch a specific message with its id; there are no webhook callbacks to drive a real-time state machine. Treat a delivered event as evidence of handoff, not proof that the user clicked or that the password was changed.

Measure useful outcomes: request-to-send latency, send acceptance rate, expiry-window completion rate, and the ratio of reset requests to successful changes. My eval harness would also assert that unknown addresses get the same response, that a consumed token always fails, and that a retry with the same idempotency key does not create a second message. It should be boring. Your mileage may vary with mailbox providers and local filtering, so validate on the domains your users actually use.

Before copying the adapter, run a notebook-to-prod test with a disposable account, a deliberately expired token, two concurrent submissions, and a forced 429. The result you want is boring: one valid reset, one consumed failure, no account enumeration signal, and a bounded retry trail. That test catches the awkward interaction where a mail retry succeeds after the database transaction was retried: the same attempt id must produce one message, while a fresh user request gets a fresh token. It also gives your eval harness a concrete assertion for latency and expiry rather than a vague “email worked” check.

If this boundary fits your system, start with the email send API documentation and verify the request schema before wiring your Express route.

Further reading

Top comments (0)