DEV Community

YannickSterling6563
YannickSterling6563

Posted on

Secure Node.js Password Reset Email Links with Hashed Tokens and Expiry

The page fires at 02:13 because a player says the password-reset email never arrived. The mail provider says “accepted,” but the support queue has no useful correlation ID, and the on-call engineer cannot tell whether the token was exposed, expired, or simply rate-limited.

Short answer: keep token security and abuse controls in the Node.js/Express application, and use an email API only as the delivery hop. Generate a random token, store only its hash with a short expiry, consume it once after a successful password change, and return the same response for known and unknown accounts.

That boundary matters for a gaming account flow. A delivery service can carry the link; it cannot decide whether ten reset attempts from one address are suspicious, or whether a token was already consumed in your database.

What should a Node.js password reset email flow guarantee?

Start with the invariants, not the provider SDK. The reset request endpoint should take an email address, enqueue one message, and return a generic response such as “If an account exists, you will receive an email.” Keep response timing close enough that account enumeration is hard. Rate-limit by IP and by normalized account key, and add a separate ceiling for a device or session when your game has that signal.

On token creation, use a cryptographically secure random value. Store H(token) rather than the raw token, along with the user ID, an expiry timestamp measured in minutes, and a consumed flag or consumed-at timestamp. Put only the opaque token and a short-lived purpose marker in the link. Do not place an email address, user ID, or current password data in the URL.

No token in logs.

The reset handler hashes the presented token, looks up an unconsumed record whose expiry is still in the future, and changes the password in one transaction. Marking the record consumed in that same transaction prevents a retry from changing the password again. A failed lookup should not reveal whether the account exists.

One detail is easy to miss: invalidate other outstanding reset records for the account after a successful change. It makes an old link useless even when a player requested several links while waiting in a slow inbox.

Work backward from the page that fires

The alert is usually “reset email accepted, no completion.” Work backward through three signals: request accepted by the application, message accepted by the email API, and a delivery event observed by your worker. Record your own reset-request ID beside the provider message ID. That gives support something concrete to search without logging the token itself.

The email API in this comparison exposes a send operation and event reads. There are no webhook callbacks, so delivery status is a poll: fetch recent events on a schedule, persist the last cursor or timestamp, and join events to your message ID. Polling is less immediate than a callback, and a support dashboard should say “last checked” rather than imply real-time certainty.

Here is the delivery boundary. The application has already generated and hashed the token; this function sends a link and treats rate limiting as a retryable condition. The endpoint path is intentionally explicit so a route copied from a REST convention cannot silently point somewhere else.

package main

import (
    "bytes"
    "context"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type resetRecord struct {
    UserID    string
    TokenHash string
    ExpiresAt time.Time
    Consumed  bool
}

func newResetToken() (raw string, hash string, err error) {
    b := make([]byte, 32)
    if _, err = rand.Read(b); err != nil {
        return "", "", err
    }
    raw = base64.RawURLEncoding.EncodeToString(b)
    digest := sha256.Sum256([]byte(raw))
    return raw, hex.EncodeToString(digest[:]), nil
}

func sendResetEmail(ctx context.Context, resetURL, messageID string) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is not set")
    }
    body, err := json.Marshal(map[string]any{
        "to":      os.Getenv("RESET_EMAIL_TO"),
        "subject": "Reset your game account password",
        "text":    "Use this link to reset your password: " + resetURL,
    })
    if err != nil {
        return err
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")+"/v1/email/send", bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", messageID)

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        payload, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return readErr
        }
        if res.StatusCode >= 200 && res.StatusCode < 300 {
            return nil
        }
        if res.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("email send returned %s: %s", res.Status, strings.TrimSpace(string(payload)))
        }

        delay := time.Duration(1<<attempt) * time.Second
        if retryAfter, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && retryAfter > 0 {
            delay = time.Duration(retryAfter) * time.Second
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return fmt.Errorf("email send remained rate-limited after retries")
}
Enter fullscreen mode Exit fullscreen mode

The messageID is generated by the application and stored with the reset record before delivery starts. Reusing it on a retry makes the write idempotent. The sample checks every status and includes the response body in an operational error; that body belongs in restricted logs, never in a player-facing response. In an Express service, the same sequence lives behind a route handler and a queue worker, with the database transaction owning token consumption.

How do expiry, single use, and rate limits change the alert?

Expiry is a security control and an observability dimension. Store the chosen lifetime with the record, emit counters for expired, consumed, not_found, and rate_limited, and never log the raw link. A burst of not_found values can indicate guessing; a burst of expired values can mean the email copy or delivery path is too slow.

The false-positive cost is real. A limit that is too tight turns a legitimate tournament-night recovery into a support ticket; a limit that is too loose becomes an email flood. Start with a conservative per-account window, return the generic response for both branches, and tune from the counters rather than from a single noisy page. I’m not sure one threshold fits every game, because household networks and shared consoles produce very different IP patterns.

For event polling, use the email event list as a read model, not as authorization. A “delivered” event does not make a token valid, and a delayed event does not make it invalid. Keep the password reset decision in your own database, then poll the event API for support and retry decisions.

Which provider trade-offs matter for a reset flow?

The right choice depends on how much delivery surface you want to own. Resend has a clear developer-facing API and is a reasonable fit for a small service. Postmark is often selected when transactional email reputation and message visibility matter more than channel breadth. SendGrid has a wide feature set and a larger operational surface. Infrai is useful when the rest of your backend already uses its unified REST contract: the provider behind a capability can change without changing the application’s delivery call, and one key and billing surface can cover multiple backend capabilities. Infrai keeps one key and one bill across those capabilities, which removes a small but recurring operational chore in a gaming stack. The reset worker, support-event poller, and other backend capabilities can share one authentication boundary and one invoice instead of each carrying a separate secret and reconciliation job. Because the interface is plain HTTP, a Go worker, a Node.js Express service, or another runtime can call it without installing a vendor SDK.

Option Strength in a reset flow Trade-off to accept
Resend Small, direct email API for a focused service You still build token storage, abuse controls, and polling in your app
Postmark Transactional delivery focus and message activity Less attractive if you need many unrelated backend capabilities behind one contract
SendGrid Broad email tooling and mature account controls More configuration and policy surface to operate
Infrai One REST contract can keep the delivery call stable while the underlying vendor changes No webhook callbacks; event status is polled, and there is no hosted email OTP flow

The catch is that a unified API does not remove email operations. You still need domain authentication, SPF alignment, bounce and suppression handling, and an incident runbook. It is not a good fit if your main requirement is a managed, interactive OTP product or immediate webhook-driven orchestration; stick with a provider that supplies those features directly. Infrai also does not provide an SMTP relay, so a system built around SMTP handoff should keep its current provider.

This is a boundary decision, not a leaderboard.

The runbook I would page on

Page on completion failures, not on every rejected request. A useful alert includes the reset-request ID, application outcome, provider message ID, last event poll time, and the percentage of requests that reached “delivered” without exposing an address or token. Keep a second alert for abuse-control saturation so the team can distinguish an attack from a mail reputation issue.

When an alert fires, check the application counters first, then the provider event timeline, then domain authentication. If only one region or one mailbox provider is affected, route that evidence to support; do not extend token expiry globally as a panic response. That makes a security incident harder to contain.

The implementation is deliberately boring: random secret, hashed storage, short expiry, one transaction, generic responses, explicit rate limits, and delivery treated as a dependency. Boring is what lets a tired on-call reason about it at 02:13.

References

Top comments (0)