DEV Community

SiegfriedFletcher5869
SiegfriedFletcher5869

Posted on

Webhook-Free OTP Login: Polling Status and SMS Verification Implications

The most important trade-off is template ownership: keep password-reset copy and expiry rules in the edtech application, even if a messaging service renders or transports the SMS. TL;DR: when a provider has no delivery webhooks, polling improves operational visibility, but it must never decide whether a reset credential is valid. Your state machine should own issuance, verification, expiry, retry, and abuse controls. Provider status is delayed transport evidence, not authentication truth.

Pick this model Template owner Delivery evidence Best fit Main cost
Application-rendered Application repository Poll status when available Copy, localization, and expiry need deterministic tests More message plumbing in code
Provider-hosted Messaging account Poll the same provider Communications teams change copy outside a release Configuration drift needs controls
Split template Application owns variables and policy; provider owns layout Poll and reconcile asynchronously Several channels share governed content Harder ownership boundaries

That is the field guide. The rest makes the first option work under an awkward constraint: a learner requests a password reset, the credential expires quickly, and the SMS provider exposes status only through polling.

How should an OTP login provider poll status without webhooks?

The verifier does. Not the SMS status endpoint.

Model two related timelines. In the authentication timeline, the application issues an opaque, single-use credential, stores only what it needs to verify it, applies an expiry, and consumes it after a valid attempt. In the transport timeline, a worker submits a rendered message, records the provider's opaque message identifier, and later polls for transport status. A correlation ID connects them, but neither timeline overwrites the other.

Here is the diagram in words: browser to reset API; reset API to credential store; outbox to SMS adapter; adapter to carrier path. A separate poller goes from the outbox record to the provider status resource, then writes an observation. The browser never waits for that loop.

This separation matters because delivered cannot prove the intended learner read the message, while pending cannot prove the message will fail. Successful credential verification is authentication evidence. A transport state is an operations signal. Keep those nouns apart in logs and dashboards.

No guesswork.

The same implications show up in hotel check-in verification: a guest may request an OTP login code at the front desk, retry while the first SMS is still moving, and then receive two messages out of order. An edtech password reset has a different job and owner, but the status problem is identical. The Node.js service cannot infer that the first credential is unusable just because polling has not reached a terminal state. Nor can it treat a later delivered observation as proof of verification. In both scenarios, the application chooses which credential is active, the verifier records which attempt succeeded, and the polling worker reports transport evidence after the fact. That clean split is abuse prevention too: it stops a retry or resend action from silently changing authentication state through a messaging callback.

Pick application-owned templates when policy lives in code

Application ownership fits when the expiry shown to the learner must match the expiry enforced by the verifier. Put copy, the variable schema, locale fallback, and expiry configuration under the same review process. Test rendered output before deployment.

Short copy wins. Include the code, its purpose, the expiry in plain language, and a warning not to share it. Never put the code in a URL, log, analytics event, or metric label. Apple's security-code AutoFill documentation and the WebOTP specification describe client extraction mechanisms; treat autofill as an enhancement because support and permissions differ.

The sharp edge is drift. If product copy says “expires in 10 minutes” while the verifier uses 5 minutes, the message creates confusion before transport starts. Use one duration value to construct both stored expiry and the template variable. That is a concrete reason to own the template beside policy rather than copying an expiry into a remote editor.

Pick hosted or split templates for release independence

A hosted template can fit when a communications team must update translations or regulated wording without shipping the authentication service. The boundary stays explicit: the application owns credential semantics and supplies typed variables; the remote template owns presentation. Review template revisions through the team's change process, and run a synthetic render for every supported locale.

A split model helps when email and SMS share approved vocabulary, but it creates two deployment surfaces. Record the template identifier and revision with each attempt so an operator can reconstruct what should have rendered without logging the secret.

Template ownership does not change the reset API response. OWASP recommends consistent messages and timing for existing and nonexistent accounts so the endpoint does not become an account-enumeration oracle. Queue the send and return the same user-facing result in both cases.

Implement polling as an observation loop

This generic TypeScript accepts a transport adapter, keeps authentication state elsewhere, adds bounded jittered polling, and converts provider-specific states into a small internal vocabulary. Its durations are illustrative application policy, not claims about any carrier or service.

type DeliveryState = "queued" | "in_transit" | "delivered" | "failed" | "unknown";

interface SmsTransport {
  readStatus(messageId: string): Promise<{ state: DeliveryState; code?: string }>;
}

interface ObservationStore {
  append(value: {
    messageId: string;
    state: DeliveryState;
    observedAt: Date;
    providerCode?: string;
  }): Promise<void>;
}

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function observeDelivery(
  transport: SmsTransport,
  store: ObservationStore,
  messageId: string,
  delaysMs = [2_000, 5_000, 10_000, 20_000, 40_000],
): Promise<DeliveryState> {
  for (const baseDelay of delaysMs) {
    const jitter = Math.floor(Math.random() * Math.max(1, baseDelay / 4));
    await sleep(baseDelay + jitter);

    const result = await transport.readStatus(messageId);
    await store.append({
      messageId,
      state: result.state,
      providerCode: result.code,
      observedAt: new Date(),
    });

    if (result.state === "delivered" || result.state === "failed") {
      return result.state;
    }
  }
  return "unknown";
}
Enter fullscreen mode Exit fullscreen mode

Five polls demonstrate bounded work, not a universal schedule. Choose the real cadence from documented rate limits, credential lifetime, and measured status latency. Jitter prevents many reset requests from synchronizing. Stop at a deadline. Preserve unknown; do not translate a missing update into failure.

Keep it bounded.

The send path needs a durable outbox or equivalent handoff. Generate one idempotency key per logical message, persist it before dispatch, and reuse it when the worker retries. Provider handling is adapter-specific, so the application must still prevent multiple active reset credentials from creating a resend storm.

Instrument the boundary with counters for submit attempts, accepted submissions, terminal observations, and polling errors. Track histograms for submit latency, time to a terminal transport state, and time to successful verification. Use low-cardinality dimensions such as channel, normalized state, and template revision. Phone numbers, codes, reset tokens, and raw error text do not belong in metric labels.

Alert on ratios and age, not one failed SMS. Useful signals include a sustained rise in normalized failures, an old queue with no observation, and a widening gap between accepted sends and successful verifications. The last signal is broad by design: it can reveal transport trouble, confusing copy, an expiry that is too short, or abuse. Show those layers side by side rather than pretending one metric names the cause.

Abuse prevention starts at the retry boundary

A learner pressing “resend” should get calm, deterministic behavior. Disable the control for a visible cooldown, mask the destination, and keep the response generic. On the server, rate-limit by several signals such as account, destination, session, and network context; one key is easy to rotate or can unfairly block a shared campus network.

Decide whether resend reuses the current credential or invalidates it and issues another. Document the choice. A new credential gives a simple rule—only the newest code works—but delayed messages can arrive out of order, so the learner may enter an older code. Reuse reduces that confusion but extends exposure of the same secret. The right choice depends on the threat model and support burden.

Verification attempts need a separate limit from send attempts. NIST's digital identity guidance requires rate limiting for short secrets and identifies PSTN out-of-band authentication as restricted. Pair attempt limits with single use, short expiry, secure random generation, and neutral errors. Do not reset the attempt counter merely because another SMS was requested.

Fast feedback helps. Never promise “delivered” based on an accepted API response, and do not make the learner wait for polling. Say a message was sent if the account is eligible, expose resend only after the cooldown, and provide a recovery path after repeated failure.

The UI stays honest.

Limits

Polling cannot create instant delivery events, prove handset receipt, or repair poor mobile coverage. It also consumes request quota as volume grows. Bound it, schedule it off the request path, and retain observations long enough to debug aggregate behavior without retaining secrets.

The durable rule is concise: authentication owns truth; messaging owns transport evidence; templates have one named owner. With those boundaries visible in code and telemetry, missing webhooks remain an operational constraint rather than a reason to weaken verification UX.

References

Top comments (0)