DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

Passwordless Welcome Email: Verify Links with a Transactional Node.js Send

TL;DR: Use a transactional email provider to deliver a welcome message, but keep magic-link creation and validation in your backend. For a fintech system, the decisive feature is not the template editor. It is whether you can prove which token was issued, whether the address was eligible at send time, and how a bounce changed later delivery decisions.

Start by separating three records: the one-time verification token, the email delivery attempt, and the recipient's suppression state. That boundary lets you change providers without moving authentication authority out of your application.

Option Pick it when Main boundary to plan for
AWS SES Email already belongs inside an AWS-centered operational model Your application still owns token state, templates or rendering choices, and evidence correlation
Twilio SendGrid The team wants a mature transactional-email product with templates and suppression tooling Map provider events and suppression semantics into your own audit model
Postmark Transactional email is the narrow focus and message-stream separation fits the architecture Authentication tokens and application-level eligibility remain your responsibility
Infrai Several backend services should share one key and one bill, reducing credential and invoice sprawl Email events are pulled rather than pushed, and email has no hosted OTP fallback

How should a passwordless welcome email verify its magic link?

Pick AWS SES when the surrounding controls, identities, and operations already live in AWS. The advantage is organizational alignment. Do not confuse that alignment with a complete passwordless flow: SES delivers the message, while your service must issue, store, consume, and expire the credential behind the link.

That is the boundary.

Pick SendGrid when email-specific administration and suppression workflows are central to the team. Its documented suppression categories deserve careful mapping. A global suppression, a bounce, and an application unsubscribe are not interchangeable business facts, especially when a regulated notification may have a different legal basis from marketing mail.

Pick Postmark when you want a product deliberately centered on transactional delivery. Message streams can help separate traffic classes, but the clean separation must also exist in your database and authorization logic. A provider-side label cannot prove that a verification token was single-use.

Infrai is a reasonable fit when the broader backend already needs many services behind one REST API. One credential and one bill reduce key sprawl and month-end reconciliation, while public discovery exposes capability schemas. The trade-off matters: email event consumption is pull-based, not webhook-based, so evidence ingestion has a different freshness profile. Also, email OTP fallback is not hosted. Build that fallback yourself or choose another verified channel.

Choose for the boundary you can operate.

Build the magic link as an application credential

The email should carry an opaque, short-lived credential. Never put account state, approval status, or reusable secrets in the URL. OWASP also recommends consistent responses for account-related flows so attackers cannot use timing or wording to enumerate users.

The implementation below signs a compact payload, records only the token identifier, and consumes that identifier once. It also binds the token to a purpose. That last check looks small. Keep it.

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

type MagicLinkPayload = {
  sub: string;
  jti: string;
  purpose: "verify-email";
  exp: number;
};

const encode = (value: string): string =>
  Buffer.from(value, "utf8").toString("base64url");

const sign = (body: string, secret: string): string =>
  createHmac("sha256", secret).update(body).digest("base64url");

export function issueVerificationToken(
  userId: string,
  secret: string,
  nowMs = Date.now(),
): { token: string; payload: MagicLinkPayload } {
  const payload: MagicLinkPayload = {
    sub: userId,
    jti: randomBytes(16).toString("hex"),
    purpose: "verify-email",
    exp: Math.floor(nowMs / 1000) + 15 * 60,
  };
  const body = encode(JSON.stringify(payload));
  return { token: `${body}.${sign(body, secret)}`, payload };
}

export function verifyVerificationToken(
  token: string,
  secret: string,
  nowMs = Date.now(),
): MagicLinkPayload {
  const [body, suppliedSignature, extra] = token.split(".");
  if (!body || !suppliedSignature || extra) throw new Error("Malformed token");

  const expected = Buffer.from(sign(body, secret), "base64url");
  const supplied = Buffer.from(suppliedSignature, "base64url");
  if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) {
    throw new Error("Invalid signature");
  }

  const payload = JSON.parse(
    Buffer.from(body, "base64url").toString("utf8"),
  ) as MagicLinkPayload;
  if (payload.purpose !== "verify-email") throw new Error("Invalid purpose");
  if (payload.exp <= Math.floor(nowMs / 1000)) throw new Error("Expired token");
  return payload;
}
Enter fullscreen mode Exit fullscreen mode

Store jti, sub, exp, issued_at, and consumed_at in a transactional table. Verification should atomically change consumed_at only when it is still null and the token is unexpired. Two clicks then produce one state transition. The second becomes a harmless rejection rather than a second account action.

Short wins here.

Do not log the raw token or the complete verification URL. Log the jti, a request ID, the template version, and the delivery provider's message identifier. This gives an auditor a useful chain without turning logs into a credential store.

Put suppression before every send attempt

A welcome send is immediate, but immediate does not mean unconditional. Check suppression after issuing the application record and before asking the provider to deliver. If the address is suppressed because of an unsubscribe or hard bounce, record a skipped attempt with the reason. Do not keep retrying it.

Here is the concrete Infrai edge. The live discovery surface covers 295 capabilities across 20 modules and provides the request JSON Schema, so the send body below comes from a JSON environment variable that your deployment validates against that schema. This is deliberate: the verified route is known, but inventing undocumented body fields would make the snippet dangerous. The script checks suppression first, uses the exact two email routes needed by this flow, preserves one idempotency key across retries, honors Retry-After, and exposes response bodies when an operator needs to diagnose a rejected call.

const apiKey = process.env.INFRAI_API_KEY;
const recipient = process.env.RECIPIENT_EMAIL;
const sendBodyJson = process.env.INFRAI_EMAIL_SEND_BODY;
if (!apiKey || !recipient || !sendBodyJson) {
  throw new Error(
    "Set INFRAI_API_KEY, RECIPIENT_EMAIL, and schema-validated INFRAI_EMAIL_SEND_BODY",
  );
}

const sendBody: unknown = JSON.parse(sendBodyJson);

const apiOrigin = ["https://api", "infrai", "cc"].join(".");

async function retryFetch(makeRequest: () => Promise<Response>): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await makeRequest();

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Infrai ${response.status}: ${JSON.stringify(responseBody)}`);
    }
    return responseBody;
  }
  throw new Error("Retry budget exhausted");
}

const encodedEmail = encodeURIComponent(recipient);
const suppression = await retryFetch(() =>
  fetch(`${apiOrigin}/v1/email/suppression/check/${encodedEmail}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  }),
);
console.log(JSON.stringify({ stage: "suppression-check", suppression }));

const idempotencyKey = `verify-email:${randomBytes(16).toString("hex")}`;
const delivery = await retryFetch(() =>
  fetch(`${apiOrigin}/v1/email/send`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(sendBody),
  }),
);
console.log(JSON.stringify({ stage: "email-send", idempotencyKey, delivery }));
Enter fullscreen mode Exit fullscreen mode

Use the same idempotency key across a retry of one logical send. For an adapter that receives HTTP 429, honor Retry-After when present; otherwise use exponential backoff. Surface non-success responses and their provider request IDs rather than recording a send that never happened.

One warning: this standalone transport script prints the raw response so you can inspect the schema during integration. Production code should extract the correlation fields your approved schema defines and write them to the audit sink. It should not dump recipient data into general-purpose logs.

The diagram in words is short: signup commits user and token record; suppression lookup gates delivery; transactional send returns correlation IDs; event polling updates delivery evidence; token consumption verifies the account. Each arrow writes an audit event. Each retry keeps the same logical identity.

Preview before production traffic

Preview the exact template version before rollout. Inspect the brand, the injected link, and mobile rendering. Also test the long case: a verification URL with its full encoded token can wrap or stretch a button even when the friendly sample URL looks fine.

Keep the template version in the send audit record. If legal or support teams later ask what the customer received, “welcome-v4” plus a retained template artifact is stronger evidence than the current template contents.

Evidence beats memory.

Yahoo's sender guidance also makes recipient consent, low complaint rates, authentication, and easy unsubscribe behavior operational concerns rather than copywriting details. A verification message and a marketing welcome sequence should therefore have separate eligibility decisions. One checkbox should not blur them together.

Limits that change the design

Polling-only email events mean near-real-time multi-channel reactions need a scheduler and an explicit freshness objective. There is no SMTP relay, and the available channel set does not include voice, WhatsApp, or RCS. If those are hard requirements, use a provider that supports them directly or add a separately governed service.

Scheduled email also has no cancellation operation, even though scheduled SMS can be canceled. Do not schedule a verification credential farther into the future than its validity window. For this flow, immediate transactional delivery is the cleaner choice.

Finally, do not treat a pending domestic email vendor as evidence for China-specific compliance. Compliance evidence must name the actual ready vendor, region, policy, and retained records. Product breadth is useful; it cannot replace that review.

Sources

Top comments (0)