A password reset email in Node 22: token handling, DKIM, templates

Where a reset token is allowed to exist, why the response must not reveal whether an account exists, and the exact Infrai calls that send and confirm the mail.

A reset email is a bearer credential wrapped in HTML. Treat it that way and the design falls out quickly: the endpoint answers identically whether or not the account exists, the token exists in exactly three places and nowhere else, and the sending domain is authenticated well enough that a lookalike can’t be delivered in your name. Infrai’s part is two calls — a stored template and a send that takes variables rather than assembled markup — which happens to keep the token out of most of your logs.

The rest is your code, and this piece is about the parts a security reviewer asks about. We’ll use Node 22, no framework, and the Infrai email routes for the transport.

Where the token is allowed to live

ArtefactMay live inMust never live inRetention
Plaintext tokenProcess memory, the send request body, the recipient’s inboxYour application logs, the subject line, an analytics eventDuration of the request
Token hash (SHA-256)Your database, one row per reset requestAnywhere reachable without a DB credentialUntil used or expired
message_idReset request row, support toolingAs long as you keep the row
Recipient addressYour users tableThe reset request row (join instead)Account lifetime

The third row is the useful one. message_id is safe to log, safe to show a support agent, and enough to answer “did the mail go out” without anyone reading the token — which means your on-call runbook doesn’t need access to the secret at all.

That separation is the whole trick.

The endpoint must not confirm the address exists

If the response differs for a known and unknown address — different status code, different body, noticeably different timing — you’ve built an account enumeration oracle. OWASP’s forgot-password guidance has said so for years and it’s still the most common finding on this flow.

So: always answer 202, always do the same amount of work, and never surface the suppression check to the caller.

import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const TEMPLATE_ID = process.env.INFRAI_RESET_TEMPLATE_ID ?? "";
const TTL_MS = 15 * 60 * 1000;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const hash = (value) => createHash("sha256").update(value).digest("hex");

async function call(path, init = {}) {
  const res = await fetch(`${API}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
    throw new Error(`${path} -> ${err.code}: ${err.message}`);
  }
  return payload.data;
}

// `db` is your own storage; the two methods below are the only ones this needs.
export async function requestReset(db, address) {
  const user = await db.findUserByEmail(address);
  const token = randomBytes(32).toString("base64url");

  if (user) {
    await db.insertResetRequest({
      user_id: user.id,
      token_hash: hash(token),
      expires_at: new Date(Date.now() + TTL_MS).toISOString(),
      used_at: null,
    });
    const sent = await call("/v1/email/send", {
      method: "POST",
      body: JSON.stringify({
        to: address,
        from: "security@auth.example.com",
        template_id: TEMPLATE_ID,
        template_vars: {
          reset_url: `https://app.example.com/reset?t=${token}`,
          minutes: String(TTL_MS / 60000),
        },
      }),
    });
    await db.attachMessageId(user.id, sent.message_id);
  }

  return { status: 202, body: { message: "If that address has an account, a reset link is on its way." } };
}

export async function completeReset(db, token, newPassword) {
  const row = await db.findResetByHash(hash(token));
  if (!row || row.used_at || Date.parse(row.expires_at) < Date.now()) return { status: 400 };
  const a = Buffer.from(row.token_hash);
  const b = Buffer.from(hash(token));
  if (a.length !== b.length || !timingSafeEqual(a, b)) return { status: 400 };
  await db.setPassword(row.user_id, newPassword);
  await db.markResetUsed(row.id);
  return { status: 204 };
}

Note what requestReset returns to the caller: the same object either way, with no branch on whether the send succeeded. Note also what it logs, which is nothing — if you add logging, log sent.message_id and the user id, never template_vars.

The template holds the markup, the send holds the secret

Create the template once. It’s free, and it means the reset URL arrives as a variable rather than as a string you concatenated in a request handler.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/template/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "password-reset-v1",
    "subject": "Reset your password",
    "html": "<p>We received a request to reset your password.</p><p><a href=\"{{reset_url}}\">Choose a new password</a></p><p>This link expires in {{minutes}} minutes. If you did not ask for it, ignore this email.</p>",
    "variables": {"reset_url": "string", "minutes": "string"}
  }'

Then every send is small, and the only sensitive field is template_vars:

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to":"user@example.com","from":"security@auth.example.com","template_id":"tmpl_9xKbQ2tVrLmA7dEwPz4N","template_vars":{"reset_url":"https://app.example.com/reset?t=REDACTED","minutes":"15"}}'
{
  "ok": true,
  "data": {
    "message_id": "msg_4bTaRk9uWQ2mHvXeLpc7VqZs",
    "from_used": "security@auth.example.com",
    "mode": "verified_domain",
    "accepted_recipients": ["user@example.com"],
    "suppressed_recipients": []
  }
}

Never put the token in the subject — subjects show up in notification previews, in mail client search indexes, and in more log pipelines than you’d expect.

Authenticating the sender is an anti-phishing control

SPF, DKIM and DMARC aren’t only about the spam folder here. A reset email is the highest-value message to spoof, and a p=reject DMARC policy on the domain you send resets from is what stops a forged one being delivered at all. Verification returns the exact records to publish, and the domain read shows each check independently.

curl -sS "https://api.infrai.cc/v1/email/domain/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "records": [
      {
        "domain": "auth.example.com",
        "domain_id": "dom_bf6PJQEPtmPrI0UfspscoWpF",
        "status": "verified",
        "checks": { "spf_dns": "verified", "dkim_dns": "verified", "tracking_cname": "verified", "dmarc_dns": "verified", "mail_loopback": "verified" },
        "warm_up_state": "in_progress",
        "daily_limit_current": 50000
      }
    ],
    "next_cursor": null
  }
}

Use a dedicated subdomain for security mail and don’t share it with anything promotional — reputation is per-domain, and a marketing complaint spike shouldn’t be able to delay a reset. Worth flagging: custom sender domains are a paid-plan feature here, so POST /v1/email/domain/verify answers 402 PRO_REQUIRED on a standard account, and until you upgrade your resets go out under a shared sender you don’t control the DMARC policy of.

Confirming it went out, without touching the secret

curl -sS "https://api.infrai.cc/v1/email/get/msg_jiAQ671ekGVqfGXj1LL27Gac" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

That returns state, to, vendor and created_at for one message — enough for a support agent to say “it left our side at 09:14” and no more. Reset links that expire before anyone clicks show up as EMAIL_EXPIRED in some flows, and a 15-minute TTL is a defensible default; 60 minutes is defensible too if your users are mostly on corporate mail with slow scanning.

Cost, and what stays free

Only the send is metered: $0.000115 per recipient, verified 2026-07-26, with a new account’s $2 credit covering roughly 17,391 messages. Template creation, template preview, domain reads, message reads and the event feed are free and rate-limited. Rates drift downward as vendor discounts land, so read the current one rather than trusting this line:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"

Where another provider fits better

Postmark separates transactional from broadcast traffic at the account level and won’t let you send bulk mail through the same stream, which is a genuine reputation advantage for security email. Mailgun’s authentication tooling and its documented reset-workflow guide are more detailed than anything here if domain authentication is the part you’re least sure about.

The limitations on this surface are the same three as everywhere else in the email namespace: no outbound webhooks, custom domains behind a paid plan, and per-message event queries rather than a global stream. None of those change the token design — which is the part that actually decides whether this flow is safe.

References

Browse more email developer guides