DEV Community

evanshepherd5623
evanshepherd5623

Posted on

Node.js SMS OTP Guide: Screening Blocked Numbers Before Report Emails

A property report should not leave the system merely because an SMS API accepted a request. The reliable Node.js design checks a suppression list before creating an OTP, records delivery without logging secrets, and authorizes email only after one successful 2FA verification. A suppressed number stops immediately. An ambiguous SMS outcome stays pending; it never becomes proof of possession.

That distinction matters when the payload is a generated owner statement or inspection report. The attachment may contain tenant names, balances, or maintenance details. SMS is one control in the release path; email is the delivery channel for the report.

Pattern Pick this when Reliability trade-off
Local suppression check, then SMS The application owns recipient policy Requires consistent writes and deliberate cache invalidation
Synchronous delivery confirmation The transport can return a final outcome inside the request budget Couples user latency to an external system
Asynchronous delivery state Delivery can outlive one request and retries need durable state Adds a worker and an explicit pending experience

For this flow, pick asynchronous state. Diagram in words: browser asks for access; the API checks policy; a worker requests delivery; a delivery update changes the attempt; the user submits the code; successful verification releases exactly one email job. Each arrow gets a correlation ID. None carries the OTP into logs.

How should Node.js 2FA check an SMS OTP suppression list?

Only verified possession should authorize it. A transport accepting an SMS is evidence of submission, not evidence that the intended person received or entered the code. Keep those events separate in the data model.

A useful sequence is requested -> dispatch_pending -> dispatched -> verified -> consumed. Terminal branches include suppressed, expired, and attempts_exhausted. A delivery failure can move an attempt to a retryable or terminal state under your policy, but it must not skip to verified.

Simple rule.

The suppression decision belongs before code generation and queue publication. Check a normalized phone-number key against active suppression records, including a reason and effective time. Check again in the worker if queues can be delayed; policy may have changed after the request was accepted. This is deliberate duplication.

Queues wait.

OWASP's forgot-password guidance maps well to this authentication edge: return consistent responses for existing and nonexistent accounts, keep response timing consistent, rate-limit attempts, use random single-use codes, store them securely, and expire them. The auth API can return the same neutral result for a suppressed number and an eligible one: 202 with an opaque attempt ID. Internal state differs. The public response does not reveal whether a phone number is registered or blocked. Expiration, guess limits, and resend limits belong in explicit configuration, with changes reviewed against observed completion and abuse signals; risk, support load, and user latency pull those controls in different directions.

Pick local policy for suppression decisions

A property manager may block a destination after an account-security request, a recipient correction, or an internal compliance action. Those are application facts. Keep them in an application-owned table keyed by a canonical phone representation, and retain the reason as restricted operational metadata.

Do not treat suppression as a failed-send counter. Delivery outcomes and recipient policy answer different questions. One says what happened to a message. The other says whether a message may be attempted. Blending them makes dashboards look tidy while hiding the decision operators need to audit.

Use a strongly consistent read where policy changes must take effect immediately. If a cache is necessary, define its maximum staleness and invalidate it on every suppression change. There is a clear trade-off: a shorter cache improves policy freshness but increases load on the source of truth.

Pick asynchronous state for uncertain delivery

The request handler should persist intent and return quickly. A worker performs the second suppression check and calls an adapter. The adapter is generic; changing transports should not rewrite the authorization state machine.

The hard operational boundary is ambiguous failure. If a timeout occurs after the external system may have accepted a request, blindly generating a second code creates two valid challenges. Retry the same durable dispatch operation with an idempotency key, or mark it uncertain and reconcile it through the transport's delivery-update path. Never infer delivery from elapsed time.

That trade-off is blunt: waiting protects the one-code invariant but makes the login feel slower; immediate resend feels responsive but can create competing codes and confusing support cases.

For observability, emit low-cardinality counters such as otp_requested_total, otp_suppressed_total, otp_dispatch_failed_total, otp_verified_total, and report_email_queued_total. Add a histogram for time from request to verification. Logs should carry the attempt ID, report ID, state transition, suppression-reason category, and transport-outcome category. Exclude the phone number, OTP, report contents, and attachment. Alert on ratios over a meaningful traffic window, not one failure.

This gives a crisp before and after. Before: an SMS request and an email send appear as unrelated successes. After: one trace shows policy allowed the attempt, dispatch progressed, verification succeeded, and one report job was queued.

A minimal TypeScript implementation

Code issuance, hashing, rate limits, expiration, and durable storage sit behind interfaces because their correct implementation depends on the application's security and persistence choices. The orchestration is the point. Every transition is explicit.

import { randomUUID } from "node:crypto";

type State = "dispatch_pending" | "suppressed" | "dispatched" | "verified" | "consumed";
type Attempt = { id: string; reportId: string; phoneKey: string; state: State; codeHash?: string };

interface Attempts {
  create(value: Attempt): Promise<void>;
  get(id: string): Promise<Attempt | null>;
  transition(id: string, from: State, to: State): Promise<boolean>;
  attachCode(id: string, hash: string): Promise<void>;
}
interface Suppressions { hasActive(phoneKey: string): Promise<boolean> }
interface Codes {
  issue(): { plaintext: string; hash: string };
  matches(plaintext: string, hash: string): Promise<boolean>;
}
interface Sms {
  send(input: { to: string; body: string; idempotencyKey: string }): Promise<void>;
}
interface Jobs {
  enqueueSms(attemptId: string): Promise<void>;
  enqueueReportEmail(reportId: string, idempotencyKey: string): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The public request path creates an opaque attempt. It returns the same shape when suppression applies, avoiding an account-discovery signal. In production, enforce allowed transitions and uniqueness in the database rather than process memory.

async function requestAccess(
  reportId: string,
  phoneKey: string,
  deps: { attempts: Attempts; suppressions: Suppressions; jobs: Jobs },
): Promise<{ accepted: true; attemptId: string }> {
  const attemptId = randomUUID();
  const suppressed = await deps.suppressions.hasActive(phoneKey);
  await deps.attempts.create({
    id: attemptId,
    reportId,
    phoneKey,
    state: suppressed ? "suppressed" : "dispatch_pending",
  });
  if (!suppressed) await deps.jobs.enqueueSms(attemptId);
  return { accepted: true, attemptId };
}
Enter fullscreen mode Exit fullscreen mode

The worker checks policy again, then attaches one code to the existing attempt. Its idempotency key names the logical dispatch, so a retry does not create a new challenge.

async function dispatchOtp(
  attemptId: string,
  deps: { attempts: Attempts; suppressions: Suppressions; codes: Codes; sms: Sms },
): Promise<void> {
  const attempt = await deps.attempts.get(attemptId);
  if (!attempt || attempt.state !== "dispatch_pending") return;
  if (await deps.suppressions.hasActive(attempt.phoneKey)) {
    await deps.attempts.transition(attemptId, "dispatch_pending", "suppressed");
    return;
  }
  const code = deps.codes.issue();
  await deps.attempts.attachCode(attemptId, code.hash);
  await deps.sms.send({
    to: attempt.phoneKey,
    body: `Your report access code is ${code.plaintext}`,
    idempotencyKey: attemptId,
  });
  await deps.attempts.transition(attemptId, "dispatch_pending", "dispatched");
}
Enter fullscreen mode Exit fullscreen mode

Verification queues the attachment only after an atomic state change. That compare-and-set prevents two concurrent correct-code submissions from producing two emails.

async function verifyAndSend(
  attemptId: string,
  submittedCode: string,
  deps: { attempts: Attempts; codes: Codes; jobs: Jobs },
): Promise<{ verified: boolean }> {
  const attempt = await deps.attempts.get(attemptId);
  if (!attempt || attempt.state !== "dispatched" || !attempt.codeHash) {
    return { verified: false };
  }
  if (!(await deps.codes.matches(submittedCode, attempt.codeHash))) {
    return { verified: false };
  }
  const won = await deps.attempts.transition(attemptId, "dispatched", "verified");
  if (!won) return { verified: false };
  await deps.jobs.enqueueReportEmail(attempt.reportId, `report:${attemptId}`);
  await deps.attempts.transition(attemptId, "verified", "consumed");
  return { verified: true };
}
Enter fullscreen mode Exit fullscreen mode

There is a durability gap in that minimal function: the process could stop after changing state to verified but before publishing the email job. Use a transactional outbox so the state change and an email-job record commit together. A relay publishes the record later, and report:${attemptId} lets the email worker deduplicate repeated delivery.

Test the awkward paths. Suppress the number between API acceptance and worker execution. Submit the right code twice at the same instant. Make the SMS call time out after acceptance. Stop the process between verification and job publication. Advance the clock beyond expiration. Each test should assert state and side effects; a 200 response alone proves little.

Limits worth keeping visible

SMS possession does not establish who is reading the eventual email account, so bind the verified attempt to the same authenticated session and exact report ID. Expire it, cap guesses and resend requests, and require a fresh challenge for a different report. The authorization artifact should be narrower than the report-delivery action it permits.

Email has its own operational boundary. The generated attachment still needs content validation, size handling, access controls, and sender-domain hygiene. Yahoo's sender guidance is useful for the email side of the pipeline, but mailbox acceptance must remain separate from the OTP decision. Instrument both. Join them with the attempt ID.

The field rule is concise: suppress before work begins, verify before release, and make the release durable exactly once. Everything else is an observable state transition.

Sources

Top comments (0)