DEV Community

LachlanHolm6518
LachlanHolm6518

Posted on

Fintech 2FA SMS OTP Auditability — Node.js Rules for US/EU Login Builders

Short answer: a 2FA login SMS OTP API is fit for audit when its lifecycle is observable from issue through resend, cancellation, and verification. For US and EU app builders, template ownership matters more than a long feature checklist: keep message text and policy in your repository, while treating the transport as replaceable.

Audit first.

The useful mental model is a state machine, not a message. An OTP is issued, delivered, superseded, verified, expired, or cancelled. Every transition needs an event ID that your compliance system can retain. I learned this after an early prototype logged only send() responses; a support ticket then took 47 minutes to reconstruct because the resend had no link to the original challenge. Tiny log. Big hole.

A field guide to template ownership

Start with this decision table before comparing endpoints. It keeps the design question concrete.

Ownership choice Pick this when Main risk to test
Application-owned template Compliance, localization, and copy review must happen in your pull request Your release process must version text and translations
Provider-owned template A small team needs a hosted editor and accepts external change control A copy change can outrun your audit approval
Hybrid template You need local policy decisions with a managed delivery channel Two systems can disagree about the active version

For a regulated sign-in, application-owned is the conservative default. Store a template ID and revision alongside each challenge. The rendered SMS should be treated as an output, never as the source of truth. A reviewer can then answer: which wording, locale, and policy produced this code?

Provider-owned templates are reasonable for a prototype or a team without a release pipeline. They become awkward when legal asks for the exact text sent on a specific date. Hybrid ownership can work, but only if one system is authoritative and the other is continuously reconciled. Otherwise the audit trail turns into a blame trail.

What should a 2FA login SMS OTP lifecycle record for US/EU apps?

Use a challenge record with immutable facts and explicit transitions. A minimal record might contain challengeId, userId, purpose, locale, templateRevision, issuedAt, expiresAt, status, attemptCount, and a salted hash of the code. Keep phone numbers out of ordinary application logs; retain a keyed, redacted reference instead.

Here is a transport-neutral TypeScript sketch. It is deliberately boring: policy lives in your service, and the SMS adapter only moves bytes.

type ChallengeStatus = "issued" | "sent" | "verified" | "expired" | "cancelled";

interface Challenge {
  id: string;
  userId: string;
  locale: "en-US" | "en-GB" | "fr-FR" | "de-DE";
  templateRevision: string;
  status: ChallengeStatus;
  issuedAt: string;
  expiresAt: string;
  attemptCount: number;
  codeHash: string;
}

interface SmsTransport {
  send(input: { to: string; body: string; idempotencyKey: string }): Promise<{ providerRef: string }>;
  cancel(input: { providerRef: string }): Promise<void>;
}

async function resend(challenge: Challenge, to: string, transport: SmsTransport) {
  if (challenge.status === "verified" || challenge.status === "cancelled") {
    throw new Error("challenge_not_sendable");
  }

  const idempotencyKey = `otp:${challenge.id}:${challenge.attemptCount + 1}`;
  const result = await transport.send({
    to,
    body: renderTemplate(challenge.locale, challenge.templateRevision),
    idempotencyKey,
  });

  await appendEvent({
    type: "otp.sent",
    challengeId: challenge.id,
    providerRef: result.providerRef,
    attempt: challenge.attemptCount + 1,
  });
}

function renderTemplate(locale: Challenge["locale"], revision: string): string {
  return `[${locale}/${revision}] Your sign-in code is {{code}}. It expires soon.`;
}

async function appendEvent(event: Record<string, unknown>) {
  // Write to an append-only store with a server timestamp and request ID.
  console.log(JSON.stringify(event));
}
Enter fullscreen mode Exit fullscreen mode

The idempotencyKey prevents a client retry from creating a second logical send. Your database transaction should reserve the next attempt before calling the transport, then mark the event with the returned provider reference. If the network drops after the provider accepts the request, a reconciliation worker can query delivery status using that reference; it should not blindly send again.

Cancellation is a policy transition. When a user changes their phone or completes login elsewhere, mark the challenge cancelled first, then ask the transport to cancel an in-flight message when that capability exists. A late SMS is still harmless if verification checks status, expiry, attempt count, and the code hash. The cancel call is an optimization; the server-side state check is the control.

Observability that survives a compliance review

Emit one event per state transition: otp.issued, otp.sent, otp.delivered (when the transport reports it), otp.resent, otp.cancelled, otp.verified, and otp.expired. Give every event a correlation ID, challenge ID, template revision, country policy, and outcome. Do not log the OTP, full phone number, or message body.

Metrics should expose behavior rather than vendor scorecards. Track issue-to-verify latency, resend rate per challenge, cancellation rate, verification failure reasons, and delivery callbacks by country and carrier category. Alert on a sudden change from the service's own baseline, then inspect sampled traces with redacted attributes. Your mileage may vary: carrier routing and user behavior can move these numbers without an application deploy, so alerts need a sensible window.

DKIM is a useful reminder that message evidence has layers: a signed message can establish domain-level integrity, but it does not prove that a particular OTP was verified. Keep transport evidence and authentication evidence separate, and link them with IDs rather than treating one as a substitute for the other.

Test the failure paths before selecting an API

Write contract tests against a fake transport, then run a small regional matrix. The matrix should cover en-US, en-GB, fr-FR, and de-DE templates, duplicate resend requests, callback reordering, expired challenges, and cancellation racing with verification. Assert invariants: one active challenge per login attempt, no code accepted after cancellation, and no template revision changing mid-challenge.

For integration tests, record request and response metadata without storing message content. Exercise timeout, rate-limit, and malformed-callback cases. A useful test is to deliver the otp.delivered callback before the otp.sent callback; your event store should preserve both and your dashboard should still derive the final state. I also run a replay with 30 duplicate callbacks, two locales, and a cancelled challenge arriving halfway through verification. The expected result is boring: one terminal state, one audit chain, and no code accepted after cancellation. If the dashboard cannot explain that replay, the interface is not ready for a compliance workflow. Observability is part of the API contract.

The Anthropic tool-use guide makes a parallel point for agent integrations: define tool inputs and outputs precisely, then make the caller responsible for orchestration. Apply that boundary here. The SMS adapter reports transport facts; your login service owns authorization, retries, and audit policy.

This approach is not suitable when your team cannot operate an append-only event store or review template changes. In that case, a hosted template workflow may be the safer short-term choice, with a scheduled export for audits. Stick with application-owned templates when legal review, localization, or incident reconstruction is a release requirement.

No API can guarantee delivery to every US or EU handset, and no cancel endpoint can retract a text already displayed. Treat those as capability boundaries, not promises to paper over with retries. Select the transport that exposes stable request IDs, delivery callbacks, and an explicit cancellation method; then keep the policy and evidence in your own service. That is the decision rule that remains useful when providers, routes, or pricing change.

References

Top comments (0)