DEV Community

JethroRhodes8268
JethroRhodes8268

Posted on

How to Ship Node.js Verification Links for Property-Management Login 2FA: Email or SMS

Short answer: Use email verification codes as the default for property-management login 2FA, with SMS OTP as a rate-limited fallback when a tenant has no dependable inbox.

The cheapest channel is the one that reaches the tenant on the first try. For a property-management signup, this policy usually minimizes integration work while still protecting people who signed up with a phone number on a leasing kiosk.

Decision note: choose the delivery path before the provider

Constraint Email code SMS OTP
Integration effort SMTP or email API, template, bounce handling Messaging API, sender identity, country rules
Deliverability signal Domain reputation, SPF/DKIM/DMARC, spam placement Carrier filtering, number formatting, routing
Security boundary Mailbox compromise and forwarded codes SIM swap, recycled numbers, lock-screen previews
Best property workflow Tenant has an inbox and can open a link Tenant has a phone but no reliable inbox

The recommendation is a policy, not a permanent winner: email first for normal signup, SMS only when the user has verified a phone number and asks for another route. Keep both channels behind one VerificationSender interface so changing that policy does not rewrite the login service.

How should a Node.js signup handle email codes and SMS OTP?

Treat the code as a short-lived credential. Generate it with a cryptographic random source, store only a hash, bind it to the account and purpose, and accept it once. The link can carry an opaque token; the code endpoint should still require the same server-side record. Never put the raw code in logs, analytics events, or a URL query string.

Here is a small TypeScript core. The transport is intentionally generic, so the same tests work with an email adapter or an SMS adapter.

import { createHash, randomInt } from "node:crypto";

type Channel = "email" | "sms";

type PendingChallenge = {
  accountId: string;
  purpose: "signup" | "login";
  channel: Channel;
  codeHash: string;
  expiresAt: number;
  attempts: number;
  used: boolean;
};

const hashCode = (code: string) =>
  createHash("sha256").update(code).digest("hex");

export function issueChallenge(
  accountId: string,
  channel: Channel,
  now = Date.now(),
): { code: string; record: PendingChallenge } {
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
  return {
    code,
    record: {
      accountId,
      purpose: "signup",
      channel,
      codeHash: hashCode(code),
      expiresAt: now + 10 * 60 * 1000,
      attempts: 0,
      used: false,
    },
  };
}

export function verifyChallenge(
  record: PendingChallenge,
  suppliedCode: string,
  now = Date.now(),
): boolean {
  if (record.used || record.expiresAt <= now || record.attempts >= 5) return false;
  record.attempts += 1;
  if (hashCode(suppliedCode) !== record.codeHash) return false;
  record.used = true;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The numbers here are policy defaults to test, not universal security constants. Ten minutes and five attempts are reasonable starting points for a leasing signup, but a threat model and support data should settle the final values. Your mileage may vary when tenants share devices or when a building has poor cellular coverage.

What breaks deliverability in US and EU login 2FA?

Email fails quietly. A message can be accepted by the recipient server and still land in spam, or a forwarded corporate address can reject it after the account is created. Publish SPF, DKIM, and DMARC for the sending domain, use a stable From identity, and process bounce and complaint events. Google’s sender guidance is a useful baseline, but it does not guarantee inbox placement.

SMS fails differently. Normalize numbers to E.164 before dispatch, keep the message short, and test Unicode carefully: GSM-7 and UCS-2 can change segmentation and therefore the number of message parts. Country-specific sender rules and carrier filtering matter more than a polished SDK. Record the provider message ID, country, channel, and outcome so support can distinguish a bad number from a delayed route.

I once assumed a six-digit code was the whole delivery problem. It wasn't. The expensive incident was a retry loop that sent four valid codes in under a minute; users entered the oldest one, and our support queue filled with “invalid code” reports. A per-account cooldown, idempotency key, and “latest code wins” rule fixed the interaction without making the token longer.

Keep it boring.

For a property manager, the useful audit trail is surprisingly specific: account ID, normalized destination type, challenge ID, enqueue timestamp, dispatch result, and verification outcome. It should exclude the address and code themselves, because logs are copied into ticketing systems and retained longer than signup records. During a rollout, sample those events against the product funnel: if email accepted events are high but completed links are low, investigate authentication redirects and spam placement; if SMS dispatch is high but completions cluster by carrier or country, investigate sender registration and number parsing. A dashboard that only reports “sent” hides both classes of failure. I would also replay the same state machine with a fake clock, including a message that arrives after expiry, a user who requests email then SMS, and two browser tabs submitting the same code. Those tests cost less than a week of support triage and make the channel decision measurable.

A testable send-and-retry workflow

Keep signup state independent from transport state. On a send request, create one challenge, enqueue one message, and return a generic response whether the destination exists. On a retry, invalidate the previous challenge, enforce a cooldown, and cap sends per account, IP, and destination. The worker can then retry a transient transport failure without issuing a second credential.

interface VerificationSender {
  send(input: { destination: string; code: string; link: string }): Promise<{ id: string }>;
}

export async function sendSignupVerification(
  sender: VerificationSender,
  destination: string,
  link: string,
): Promise<string> {
  const { code, record } = issueChallenge("account-123", "email");
  await saveChallenge(record); // database transaction with the signup state
  const result = await sender.send({ destination, code, link });
  await markDispatched(record.accountId, result.id);
  return result.id;
}

declare function saveChallenge(record: PendingChallenge): Promise<void>;
declare function markDispatched(accountId: string, messageId: string): Promise<void>;
Enter fullscreen mode Exit fullscreen mode

Test the policy with fake adapters. Assert that a duplicate queue delivery does not create a new code, an expired code is rejected, five wrong attempts lock the challenge, and a delayed SMS does not reveal whether an email address exists. Add integration tests for EU and US number formats, plus an email with a non-ASCII display name to catch encoding regressions.

SMS is a poor fit when your tenants use shared numbers, when SIM-swap risk is material, or when your volume crosses country-specific registration requirements. Choose email as the only factor only when the mailbox is protected by a stronger login; an email code does not magically become phishing-resistant. Conversely, email is a poor fit for residents who receive lease links through a managed phone but rarely open that inbox. In that case, make SMS the explicit fallback and show a clear resend cooldown.

The catch is that neither channel is suitable when the account is a high-value administrative identity that needs phishing-resistant hardware keys. Stick with a stronger factor for property staff who can change rent rolls or payout details; use these codes for tenant signup friction, not as a universal security answer.

The decision should be reviewed with delivery data: first-attempt success, median and p95 latency, resend rate, fraud reports, and support contacts per 1,000 signups. Cost is one input, not the thesis. A channel that needs repeated retries is not cheap in practice, even if its listed per-message rate looks small.

References

Top comments (0)