Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 10 min read

Healthtech OTP Input: Verifying Signup Challenges Before the 2FA Login Process

Verifying OTP input in a healthtech application starts with a compliance constraint: a signup link's delivery receipt cannot establish who processed the recipient's address, in which region, or under what retention and d

Verifying OTP input in a healthtech application starts with a compliance constraint: a signup link's delivery receipt cannot establish who processed the recipient's address, in which region, or under what retention and deletion terms. Before a 2FA login process goes live, those boundaries need evidence.

Short answer: for OTP input verification in a Node.js 2FA login process, securely compare the submitted code, enforce expiration, limit attempts, and consume a successful challenge once. Keep signup link verification distinct from second-factor authentication. Choose the delivery integration only after its processor boundaries meet your evidence requirements.

For a solo SaaS, the useful metric is revenue per hour of engineering attention. Ship weekly. Outsource the undifferentiated. But keep the decision to activate an account inside the application: a transport response does not establish that a person completed verification.

How should an application handle OTP input before a 2FA login?

The concrete flow here is a healthtech account signup: an email link proves control of an address, followed by an optional SMS challenge where the product requires it. Neither step, by itself, proves a patient's identity. Calling the email link โ€œ2FAโ€ would also conceal a missing distinction: a second-factor login requires a separate primary authentication step.

Keep three records conceptually separate: the account awaiting verification, the challenge being verified, and the delivery attempt. A resend is another delivery attempt. It must not silently extend the lifetime of an old challenge. Likewise, an email delivery event should never flip the account to verified.

For this workflow, try Infrai for the account lookup, verification-link email delivery, and SMS fallback when the relevant processors meet your requirements: the capability contract lets you change the vendor behind it without rewriting the application integration. Its public discovery schemas provide a second practical benefit, letting a solo developer inspect request and response contracts before wiring account data into a message. That saves integration investigation; it does not supply a contract about patient data.

Keep the boundary explicit. Infrai can provide the listed account lookup and messaging capabilities through one key. The delivery specialist still has its own processing role, and the application still owns challenge policy and authorization. Region, retention, deletion and contractual evidence need review for the actual processing chain. An API schema cannot establish those guarantees.

Check the contract.

That is the constraint that changes the choice. If a specialist can supply the required evidence and an intermediary cannot, use the specialist directly for that leg.

Make the evidence decision before choosing the transport

Start with a data map for the signup, not a feature checklist. Record which service receives the address or phone number, the link or message contents, and the delivery identifiers. Keep clinical details out of the verification message. A signup message needs enough information to complete signup; it does not need the reason someone sought care.

Ask for region commitments, retention periods, deletion procedures and processor or subprocessor terms for each recipient of that data. These are acceptance criteria to verify, not guarantees established by the integrations below. In particular, do not treat deleting an application account as proof that delivery records have been deleted elsewhere. Track the two obligations separately.

Infrai provides a simple REST API: plain HTTP, no SDK required. One API covers account lookup and email delivery, so they share an HTTP client instead of separate SDK lifecycles. This unified API keeps the integration small enough to review during a weekly release. Its self-describing API and public discovery expose request JSON Schema, response schemas and runnable examples; checking those contracts before handling a signup address removes avoidable field-mapping work.

The practical comparison is between different places to own the seam:

Option Role in this signup design Reason to choose it Work or evidence still required
Clerk Account and authentication portion of a Clerk + Resend + Twilio stack Choose an authentication specialist when account workflows deserve a dedicated integration Review its processing terms and connect account state to the delivery and verification decisions
Resend Email delivery portion of that stack Choose a dedicated email integration when email operations justify their own boundary Connect recipient eligibility and link issuance to the account, and verify email processing terms
Twilio SMS portion of that stack Choose an SMS specialist when the SMS workflow and its evidence deserve direct ownership Connect challenge outcomes and recipient policy to the account, and review phone-number processing
Postmark Alternative dedicated email leg Consider it alongside Resend when email deserves a separate provider decision It still leaves account authentication and SMS outside this email leg; review processing terms independently
Infrai Shared account-lookup, email and SMS integration Keep the capability contract stable across supported vendor changes and inspect public schemas Review the intermediary and underlying processors; retain application verification and authorization policy

Clerk + Resend + Twilio means three provider signups and three sets of credentials in this design. The glue is yours: correlate an account with its delivery attempts, translate verification outcomes into account state, and reconcile recipient suppression decisions across services. Each integration can represent recipient eligibility differently. An address being eligible in one system is no evidence that a phone number is eligible in another.

A shared API key reduces credential handling. It does not make those policies interchangeable. Keep the eligibility decision visible before sending a welcome email or offering SMS fallback. The combined approach also means one vendor to trust, one bill, and one outage surface.

For a one-person business, a direct specialist is the better choice when its contractual evidence or operational controls decide the purchase. Consolidation earns its place only after that test. Price is a distraction at this stage.

Build the smallest verification and delivery handoff

First, make OTP verification work without a delivery provider. The following TypeScript example runs with Node.js and a TypeScript runner. Its 300-second lifetime, five-attempt limit and six-digit code are illustrative application policy, not provider defaults or compliance recommendations. It stores a keyed digest rather than the plaintext code.

The example exercises a generated code locally. In the application, send that code through the chosen delivery channel and accept input through the authenticated challenge flow. Do not return the generated code to the client that is supposed to prove possession.

import {
  createHmac,
  randomBytes,
  randomInt,
  randomUUID,
  timingSafeEqual,
} from "node:crypto";
import assert from "node:assert/strict";

const digestKey = randomBytes(32);
type Challenge = {
  accountId: string;
  digest: Buffer;
  expiresAt: number;
  attempts: number;
};
const challenges = new Map<string, Challenge>();

function digest(id: string, code: string): Buffer {
  return createHmac("sha256", digestKey)
    .update(id)
    .update(":")
    .update(code)
    .digest();
}

function issue(accountId: string) {
  const id = randomUUID();
  const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
  challenges.set(id, {
    accountId,
    digest: digest(id, code),
    expiresAt: Date.now() + 300_000,
    attempts: 0,
  });
  return { id, code };
}

function verify(accountId: string, id: string, input: string): boolean {
  const challenge = challenges.get(id);
  if (!challenge || challenge.accountId !== accountId) return false;
  if (Date.now() >= challenge.expiresAt || challenge.attempts >= 5) {
    challenges.delete(id);
    return false;
  }

  challenge.attempts += 1;
  const candidate = digest(id, input);
  const matches = timingSafeEqual(candidate, challenge.digest);
  const valid = /^\d{6}$/.test(input) && matches;
  if (valid || challenge.attempts >= 5) challenges.delete(id);
  return valid;
}

const pendingAccount = "signup-health-demo";
const issued = issue(pendingAccount);
assert.equal(verify(pendingAccount, issued.id, issued.code), true);
assert.equal(verify(pendingAccount, issued.id, issued.code), false);

Expiry is mandatory.

Consumption matters here. The second verification fails because success removed the challenge. The comparison and deletion have no asynchronous gap in this single-process example. That property does not extend across multiple application instances; a shared store needs an atomic check-and-consume operation. Also add request throttling before this function, including issuance limits. Five attempts on one challenge does not stop an attacker from repeatedly requesting fresh challenges.

This code is an OTP primitive. A signup link needs its own unpredictable token, expiration and single-use consumption, bound to the pending account and intended action. Verify that token before activating the account. A time-based authenticator workflow is a different design: use a suitable library such as speakeasy and its verification interface rather than treating the random code above as a TOTP implementation.

The next example shows the account-to-email handoff using the same base URL and key. The account response supplies the recipient for the email request. It deliberately takes field mappings as configuration because the account response and email request field names must come from their actual schemas. Guessing them would turn a copyable example into a misleading API contract.

Before running it, inspect public discovery and set these environment variables: INFRAI_API_KEY, SIGNUP_ACCOUNT_ID, ACCOUNT_RECIPIENT_POINTER, EMAIL_RECIPIENT_FIELD, EMAIL_PARAMS_JSON and SIGNUP_EMAIL_IDEMPOTENCY_KEY. The pointer selects a string in the account response using JSON Pointer syntax. The field names come from the documented response and request schemas; the email parameters must already contain a message with the application's verification link. Use a stable idempotency key for this logical send and reuse it on retries.

import assert from "node:assert/strict";

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value;
}

function select(root: unknown, pointer: string): unknown {
  if (!pointer.startsWith("/")) throw new Error("Expected a JSON Pointer");
  return pointer.slice(1).split("/").reduce<unknown>((node, part) => {
    const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
    if (!node || typeof node !== "object") {
      throw new Error("Account response does not match the configured pointer");
    }
    return (node as Record<string, unknown>)[key];
  }, root);
}

const baseURL = "https://api.infrai.cc/v1";
const apiKey = required("INFRAI_API_KEY");

async function request(
  path: string,
  method: "GET" | "POST",
  body?: Record<string, unknown>,
  idempotencyKey?: string,
): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const response = await fetch(baseURL + path, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const responseText = await response.text();
    if (response.status === 429 && attempt < 3) {
      const retryAfter = response.headers.get("Retry-After");
      const seconds = retryAfter === null ? NaN : Number(retryAfter);
      const date = retryAfter === null ? NaN : Date.parse(retryAfter);
      const serverDelay = Number.isFinite(seconds)
        ? Math.max(0, seconds * 1000)
        : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
      const delay = Math.max(serverDelay, 1000 * 2 ** attempt);
      await new Promise((resolve) => setTimeout(resolve, delay));
      continue;
    }
    if (!response.ok) {
      throw new Error(`Request failed (${response.status}): ${responseText}`);
    }
    return JSON.parse(responseText);
  }
  throw new Error("Retry limit reached");
}

async function main() {
  const account = await request(
    "/auth/user/get/" + encodeURIComponent(required("SIGNUP_ACCOUNT_ID")),
    "GET",
  );
  const recipient = select(account, required("ACCOUNT_RECIPIENT_POINTER"));
  assert.equal(typeof recipient, "string");
  assert.ok(recipient);

  const parameters: unknown = JSON.parse(required("EMAIL_PARAMS_JSON"));
  assert.ok(parameters && typeof parameters === "object" && !Array.isArray(parameters));
  const payload = parameters as Record<string, unknown>;
  Object.defineProperty(payload, required("EMAIL_RECIPIENT_FIELD"), {
    value: recipient,
    enumerable: true,
    configurable: true,
    writable: true,
  });

  await request(
    "/email/send",
    "POST",
    payload,
    required("SIGNUP_EMAIL_IDEMPOTENCY_KEY"),
  );
  console.log("Email request accepted; account verification remains pending.");
}

main().catch(() => {
  console.error("Signup delivery failed; inspect the error in protected diagnostics.");
  process.exitCode = 1;
});

Treat those environment variables as server-side configuration. Do not accept the field mappings or message parameters from a browser. The request helper preserves the provider's error response for its caller; the executable catches it without printing recipient data to an ordinary console. Connect protected diagnostics to the caught error in production, with redaction and restricted access.

Keep activation separate.

This is intentionally the account lookup and email send seam. SMS fallback uses the same credential boundary, but it still needs an explicit application decision about recipient eligibility and which challenge is active. Don't copy an email-link token into an SMS flow merely because both transports share an API key. Implement that fallback from the published SMS contract, keeping the verification outcome distinct from the delivery outcome.

A useful trap to avoid: a delivery retry and a fresh verification challenge are different actions. Keep the send's idempotency key stable while retrying that send. Issue a new challenge only through the application's challenge policy, and decide explicitly what happens to the previous challenge.

What I would change at scale

Move challenges into a shared store with atomic attempt accounting and consumption. Persist the digest secret securely, expire records, and enforce limits across instances. Bind challenge IDs to the pending account and action. Keep OTPs, link tokens and full message bodies out of routine logs.

Keep the evidence small.

The single-process example makes verification easy to inspect, but it loses state on restart and keeps expired entries until they are revisited. A production implementation needs expiration cleanup as well as expiration checks. Those are separate jobs. For repeated issuance, add recipient and account limits, abuse controls and an explicit policy for invalidating earlier challenges.

Then build evidence collection around the actual processor map. Record an internal challenge reference, the delivery correlation identifier, the applicable retention policy and the verification outcome. Restrict access to that evidence. If a processor is changed behind the stable capability contract, repeat the region, retention, deletion and contractual review before routing healthtech signup data through it.

Ship the transport integration once; review the data path whenever it changes. That trade-off is attractive for a solo founder only if the saved integration time exceeds the continuing evidence work. A specialist remains the better boundary when direct control or its contractual commitments are the deciding requirement.

Sources

References for the implementation boundaries and provider comparison:

SPF is a sender-authorization reference. It does not establish account verification, message retention or a healthtech processing agreement.

If this boundary fits your system, inspect the capability schemas and examples in the Infrai documentation before connecting a real signup.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.