Passwordless signup: one magic-link email that also does the welcome
Fold address verification and the welcome into a single message: stored template variables, a 15-minute single-use token in Node 22, and delivery you can audit.
A passwordless signup owes the new user exactly two things: proof that the address belongs to them, and a way into the product. Sending those as two separate emails within thirty seconds of each other is the most common mistake in the pattern — the second message competes with the first for attention, and neither one is the thing the user clicks. Fold them into one. On Infrai that’s a stored template with a magic_link variable, sent by template_id, and the whole loop is three calls of which only the send is billable.
The auth logic stays yours. What the email API contributes is the message body, the delivery, and an answer when someone says nothing arrived.
One email or two?
| Approach | First message | Second message | Where it breaks |
|---|---|---|---|
| Combined magic link | Verify + sign in + short welcome | None | Copy has to work for someone who hasn’t seen the product yet |
| Verify, then welcome on first login | ”Confirm your address” | Sent after the click | Two sends per signup, and the welcome can arrive days later |
| Welcome, then verify later | Product tour | Nag until confirmed | Unverified addresses accumulate, bounce rate climbs |
| Code instead of link | 6-digit code | None | Better on mobile; worse for a user reading mail on a second device |
The combined version wins for most B2B SaaS signups because it produces one click, one conversion event and one message to keep good. Auth0’s magic-link documentation makes the same argument from the auth side.
Codes deserve a mention though: if a meaningful share of your users read email on a phone and sign up on a laptop, a short code they can retype beats a link they’d have to email themselves.
The link, and its rules
Thirty-two random bytes, base64url. Store a SHA-256 hash of it, never the token. Fifteen minutes of life, single use, bound to the address that requested it, and only one active link per address — issuing a new one invalidates the previous one, which is what makes the “resend” button safe.
One more rule, and it’s the one that bites in corporate environments: don’t consume the token on GET. Mail security gateways fetch links to inspect them, and a token consumed by a scanner leaves your user staring at “this link has expired”. Serve a landing page on GET and complete the login on POST.
Store the message once
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":"passwordless-welcome","subject":"Your {{product}} sign-in link","html":"<p>Welcome to {{product}}. This link both confirms your address and signs you in.</p><p><a href=\"{{magic_link}}\">Sign in to {{product}}</a></p><p>It stops working in {{ttl_minutes}} minutes. Didn'\''t request it? Ignore this email.</p>","variables":["product","magic_link","ttl_minutes"]}'
{
"ok": true,
"data": {
"template_id": "tmpl_9WvQ4hTxJ2mCbNr7sKdE",
"name": "passwordless-welcome",
"variables": ["product", "magic_link", "ttl_minutes"],
"default_vars": {},
"created_at": "2026-07-26T08:41:03.552911Z"
}
}
Render it once against sample values before it goes anywhere near a real inbox — an unsupplied variable stays in the HTML as a literal {{magic_link}}, which is a dead link rather than an empty one:
curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_9WvQ4hTxJ2mCbNr7sKdE" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"vars":{"product":"Kettle","magic_link":"https://app.example.com/auth/callback?t=sample","ttl_minutes":"15"}}'
An empty missing_vars array in the response is the assertion to put in your test suite. The mechanics of that loop are covered in more depth in our template create, preview and send walkthrough.
Issuing the link
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to":"newuser@example.com","from":"hello@auth.example.com","template_id":"tmpl_9WvQ4hTxJ2mCbNr7sKdE","template_vars":{"product":"Kettle","magic_link":"https://app.example.com/auth/callback?t=Yk9-r2Qm","ttl_minutes":"15"}}'
{
"ok": true,
"data": {
"message_id": "msg_Ln3vBq8XsW6dTyHf2Rce",
"from_used": "hello@auth.example.com",
"accepted_recipients": ["newuser@example.com"],
"suppressed_recipients": []
}
}
template_id and template_vars replace subject and html — you send one shape or the other, not both.
Both halves of the flow, in Node 22
import { randomBytes, createHash } from "node:crypto";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const TEMPLATE_ID = process.env.WELCOME_TEMPLATE_ID ?? "tmpl_9WvQ4hTxJ2mCbNr7sKdE";
const TTL_MINUTES = 15;
const PRODUCT = "Kettle";
// Replace with your database. Keys are the token hash; one row per active link.
const links = new Map();
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;
}
const hash = (token) => createHash("sha256").update(token).digest("hex");
export async function issueLink(email) {
for (const [key, row] of links) if (row.email === email) links.delete(key);
const token = randomBytes(32).toString("base64url");
links.set(hash(token), { email, expiresAt: Date.now() + TTL_MINUTES * 60_000, used: false });
const sent = await call("/v1/email/send", {
method: "POST",
body: JSON.stringify({
to: email,
from: "hello@auth.example.com",
template_id: TEMPLATE_ID,
template_vars: {
product: PRODUCT,
magic_link: `https://app.example.com/auth/callback?t=${token}`,
ttl_minutes: String(TTL_MINUTES),
},
}),
});
if (sent.suppressed_recipients.length > 0) {
return { ok: false, reason: "address is on the suppression list" };
}
return { ok: true, messageId: sent.message_id };
}
/** Call this from POST /auth/callback, never from the GET that renders the page. */
export function consumeLink(token) {
const candidate = hash(token);
const row = links.get(candidate);
if (!row || row.used) return { ok: false, reason: "invalid" };
if (Date.now() > row.expiresAt) {
links.delete(candidate);
return { ok: false, reason: "expired" };
}
row.used = true;
links.delete(candidate);
return { ok: true, email: row.email, verifiedAt: new Date().toISOString() };
}
const issued = await issueLink("newuser@example.com");
console.log(issued);
Deleting any earlier row for the same address before writing a new one is what gives you the one-active-link property. Users click “resend” more often than you’d expect — usually because the first message is sitting in a corporate quarantine — and without that line you end up with three valid tokens in three inboxes.
The used flag plus the delete looks redundant, and in a single-process demo it is. Against a real database the flag is what lets you distinguish “already used” from “never existed” in your logs, which is the difference between a confused user and an attack.
When the user says it never arrived
curl -sS "https://api.infrai.cc/v1/email/list?limit=10" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Find the message, then GET /v1/email/get/{id} for its state and GET /v1/email/event/list for the per-recipient timeline. A bounced state means the address was mistyped at signup and no amount of resending will help — offer a different address instead of a retry loop. A user who hasn’t finished the flow is exactly the case EMAIL_CONFIRMATION_REQUIRED describes on the account side.
What the flow costs
One signup is one billable send: $0.000115 per recipient, verified 2026-07-26. Template creation, preview, message reads and event history are free and rate-limited rather than metered, so a preview assertion in CI on every deploy adds nothing to the bill. A new account’s $2 credit covers roughly 17,391 sign-in links — for most products that’s a year of signups. Rates here trend downward as vendor discounts arrive, so read today’s figure rather than this sentence:
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'))"
The drawbacks, and who does this better
Templates here are named-variable substitution — no loops, no conditionals — so a message that varies structurally by plan needs you to render the HTML yourself and post it as html. The send route takes no idempotency key, so a double-submitted signup form sends two emails unless you dedupe upstream. Custom sender domains need a paid plan; a standard account gets HTTP 402 PRO_REQUIRED from the verify route. And delivery status is polled, not pushed.
If you want the auth system rather than the email, Supabase and Auth0 both ship magic links as a built-in feature, including the token store and session handling this article leaves to you — for a greenfield app with no existing user table, that’s a genuinely better starting point. If you want the email layer alone and nothing else, Resend and Postmark are both excellent at exactly that.
The reason to run it here is the part after the click: the session record, the onboarding job on a queue, the audit event, the per-tenant usage line. Same credential, same invoice, one usage view.