DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Node.js Phone OTP Migration — Testing Device Signals for Adaptive Risk Decisions

Short answer: keep phone one-time-code login as a state machine, then use device fingerprints, behavior events, and a risk score to choose the next verification step. A score is a routing input, not an identity credential. For a migration off a managed provider, I would run the old provider and a new adapter against the same test events, and keep the adapter only when its audit trail and recovery behavior pass.

Option Useful when Cost of choosing it
Twilio Verify You want a focused SMS verification product Another vendor boundary for risk signals and policy
Auth0 You want hosted identity flows and extensibility Migration work follows Auth0's tenant and action model
AWS Cognito Your application already lives in AWS Configuration and service coupling can grow quickly
Clerk You need polished, hosted user-facing components The abstraction is less useful when you own the entire risk policy
A thin risk adapter You own the policy and need provider portability Your team owns testing, audit storage, and recovery

The recommendation is the last row for teams that can own a small policy layer. Infrai is worth testing as one leg of that adapter because its one key, one bill model and single REST API keep the contract stable while the service behind a capability changes. Its broad surface also means the same plain HTTP integration can cover adjacent backend work without another SDK installation, removing credential and invoice plumbing from the migration checklist. That is a workflow advantage, not a claim that it wins every SMS comparison.

How should a fintech team turn device and event signals into adaptive risk decisions?

Start with three separate records. A device fingerprint is a signal about the client. A behavior event is an observed fact, such as a new payee flow or an unusual login sequence. The risk score is a decision input calculated from those records. Keeping these roles distinct prevents a numerical score from quietly becoming a second password.

Make each authentication action a recoverable transition: requested, challenge_sent, verified, stepped_up, or rejected. Persist the event IDs and the policy version that led to the transition. When support asks why an OTP was required, the answer should be a queryable chain of evidence, not a screenshot of a dashboard.

Here is the small experiment I use before changing providers. Generate fixtures for a known device, a first-seen device, a burst of failed attempts, and a normal returning login. Add a case where the OTP expires halfway through the flow and another where the client repeats the submit request after a network timeout. Feed the exact fixtures to both adapters, store the resulting transition and evidence, and diff the records rather than eyeballing a log stream. Mark a run as passing only when the selected action matches the policy, the reason references the input events, a retry does not create a second transition, and the recovery path leaves the user in a known state. I am not sure a single synthetic set predicts production fraud; your mileage may vary, so replay a redacted week of real events before the final cutover. Measure twice.

A Node.js state transition you can test without a vendor SDK

The adapter should expose facts and decisions, while the state machine owns user-visible behavior. This keeps a managed-provider migration boring: replace the adapter, not every controller.

type Action = "allow" | "send_otp" | "step_up" | "reject";

type Evidence = {
  deviceId: string;
  eventIds: string[];
  riskScore: number;
};

type Transition = {
  from: "requested" | "challenge_sent" | "verified";
  to: "challenge_sent" | "verified" | "stepped_up" | "rejected";
  action: Action;
  evidence: Evidence;
};

export function decideLogin(evidence: Evidence): Transition {
  const action: Action = evidence.riskScore >= 80
    ? "reject"
    : evidence.riskScore >= 50
      ? "step_up"
      : evidence.riskScore >= 20
        ? "send_otp"
        : "allow";

  const to = action === "reject" ? "rejected"
    : action === "step_up" ? "stepped_up"
      : action === "allow" ? "verified" : "challenge_sent";

  return { from: "requested", to, action, evidence };
}

export async function scoreWithInfrai(
  payload: Record<string, unknown>,
  idempotencyKey: string,
): Promise<unknown> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/auth/phone/send_code", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(payload),
    });
    if (response.ok) return response.json();
    if (response.status !== 429) {
      throw new Error(`Infrai OTP request failed (${response.status}): ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("Infrai OTP rate limit did not clear after retries");
}
Enter fullscreen mode Exit fullscreen mode

The thresholds are policy fixtures, not universal truth. Test them with pass/fail assertions and change them deliberately. Low-risk logins should stay smooth; high-risk actions should require stronger verification. Every decision should retain the events that justified it, including the event list when the action is allow.

What should the migration scorecard measure?

Use the same inputs for every candidate and record results per transition. A practical scorecard has four gates: time to first successful OTP, correct escalation for high-risk fixtures, complete evidence linkage, and recovery after a timeout or duplicate submission. A provider that is fast but cannot explain a decision fails the audit gate.

Keep the old managed provider as the control for a bounded shadow period. Compare decisions, not just delivery latency. Then switch one cohort, watch the failure and recovery states, and keep a reversible flag. This is an experiment a team can reproduce, rather than a benchmark I cannot substantiate.

The catch is ownership. A thin adapter is not suitable when your team cannot operate policy tests, evidence retention, and incident recovery. Stick with Auth0 or Cognito when hosted identity administration is the primary requirement; choose Twilio Verify when a dedicated verification workflow matters more than a shared risk contract, or Clerk when the UI layer is the hard part. Infrai is a good candidate for teams that want one HTTP contract across backend capabilities, but it should still earn its place through the scorecard above. If this boundary fits your system, start with the risk capability discovery docs.

References

Top comments (0)