DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Phone Verification Debugging for Game Recovery — Correlating Send and Verify Evidence

Short answer: treat a phone verification attempt as one trace that spans send, delivery, and verify, then make every transition auditable before you tune a provider or retry policy.

I build tools for developers, so my first question is usually boring: can I explain one failed attempt from a single request ID? If the answer is no, the system is not ready for an audit. A player saying “the code never arrived” is not a useful diagnostic fact. It could mean the send request was rejected, the message was delayed, the client displayed an old code, or the verify endpoint received a different phone number.

This is a gaming recovery flow, where an account may be worth years of purchases and saved progress. The recovery path has to be forgiving to a tired player and strict enough to resist account takeover. Those goals pull in opposite directions.

What should a trace contain across phone verification send and verify steps?

Start with a state machine, not a pair of unrelated endpoints. Create an attempt record before sending anything. Give it a random, non-sequential attemptId, a normalized phone hash, an expiry time, and a server-side status. Never log the code itself or the full phone number.

The minimum useful states are created, send_accepted, send_rejected, verified, expired, and locked. A delivery receipt can add delivered or undeliverable, but delivery is evidence about a message, not proof that a player typed the right code. Keep those concepts separate.

Here is the smallest shape I would put behind both handlers:

type VerificationStatus =
  | "created"
  | "send_accepted"
  | "send_rejected"
  | "verified"
  | "expired"
  | "locked";

type VerificationAttempt = {
  attemptId: string;
  accountId: string;
  phoneHash: string;
  status: VerificationStatus;
  createdAt: string;
  expiresAt: string;
  sendRequestId?: string;
  verifyRequestId?: string;
  failureCode?: string;
};
Enter fullscreen mode Exit fullscreen mode

I also store a transition event with a timestamp, actor (server, carrier_receipt, or player), and a redacted reason. That event stream is what an auditor can inspect later. The current row is for fast reads; the events explain how it got there.

A useful request envelope looks like this:

type VerificationEvent = {
  attemptId: string;
  requestId: string;
  phase: "send" | "delivery" | "verify";
  outcome: "accepted" | "rejected" | "delivered" | "failed";
  code?: string;
  occurredAt: string;
};
Enter fullscreen mode Exit fullscreen mode

The requestId must be generated at the edge and passed through queues, provider adapters, and storage. If a retry creates a second provider request, keep the same attemptId but record a new requestId. That distinction exposed a real class of bugs for me: the client was polling the first request while the worker had already sent the second.

How do you isolate send, delivery, and verify failures?

Use the phase boundaries as your investigation checklist. Do not infer a send failure from a missing verification. Ask what the last recorded transition was.

Last known event What it proves Next check
No created event The recovery request never entered the auth service Check client validation and edge logs
created, no send event Queueing or worker handoff failed Inspect enqueue result and consumer lag
send_rejected The send call returned a classified rejection Check destination policy, rate limits, and request shape
send_accepted, no delivery event A message was accepted for processing Check receipt feed and carrier latency; do not call it delivered
delivered, verify rejected The code reached the destination but did not authenticate Compare attempt ID, expiry, retry count, and code version
verified The possession check passed Continue with session rotation and recovery audit logging

The table is deliberately plain. Fancy dashboards do not fix missing correlation. For each phase, record a small, stable error taxonomy such as invalid_destination, rate_limited, expired_code, mismatch, and already_used. Avoid copying a vendor's raw message into a user-visible or audit field; raw text changes and may contain phone data.

One subtle failure is clock disagreement. The send worker may stamp an expiry using its clock while the verify service uses another. Use a server-issued expiry and compare it against one authoritative time source. A five-minute code is not five minutes if the clocks disagree by ninety seconds.

Another is stale UI state. A player requests a second code, receives it first, then submits the first code from an autofill suggestion. The API should return a generic failure to the player, while the audit event can say superseded_attempt. Never reveal whether a phone number is registered. OWASP's Authentication Cheat Sheet calls out that authentication responses should avoid account enumeration signals, which applies to recovery flows too.

Three words: preserve the evidence.

Building a repeatable investigation loop

I prefer a replayable fixture over manually clicking through a game client. A fixture creates one attempt, injects a controlled send result, and then submits verify requests with explicit timing. The output is a trace that can be attached to a ticket without exposing the secret.

type SendResult = { accepted: boolean; requestId: string; code?: string };

async function investigateAttempt(
  attemptId: string,
  send: () => Promise<SendResult>,
  verify: (input: { attemptId: string; code: string }) => Promise<{ ok: boolean; requestId: string }>,
): Promise<VerificationEvent[]> {
  const events: VerificationEvent[] = [];
  const sendRequestId = crypto.randomUUID();
  const sent = await send();
  events.push({
    attemptId,
    requestId: sendRequestId,
    phase: "send",
    outcome: sent.accepted ? "accepted" : "rejected",
    code: sent.accepted ? undefined : "send_rejected",
    occurredAt: new Date().toISOString(),
  });

  if (!sent.accepted || !sent.code) return events;

  const checked = await verify({ attemptId, code: sent.code });
  events.push({
    attemptId,
    requestId: checked.requestId,
    phase: "verify",
    outcome: checked.ok ? "accepted" : "failed",
    code: checked.ok ? undefined : "mismatch",
    occurredAt: new Date().toISOString(),
  });
  return events;
}
Enter fullscreen mode Exit fullscreen mode

The fixture should cover duplicate sends, an expired code, a wrong attempt ID, a replay after success, and a verify request that arrives before the delivery receipt. Those cases matter more than a happy-path screenshot. Assert that each event has an attempt ID, that secrets are absent, and that a second successful verify is impossible.

For production, attach metrics to transitions rather than to generic HTTP status codes. Track send acceptance rate, delivery latency, verify mismatch rate, expiry rate, and lock rate by country and carrier class. A sudden mismatch spike with normal send acceptance points at client or state handling. A send rejection spike points earlier in the path. Keep dimensions bounded; putting a phone number or arbitrary provider message in a metric label creates a privacy and cardinality problem.

Logs need the same discipline. Sample successful traces, retain all security-relevant failures, and set a retention period that matches your audit policy. Encrypt the event store, restrict who can query it, and document redaction. Auditability is an access-control problem as much as a logging problem.

Trade-offs and the scale-up decision

For a small game, a relational attempt table plus an append-only event table is usually enough. It is easy to query, easy to back up, and gives investigators a consistent transaction boundary. A queue and separate event pipeline can handle larger bursts, but it introduces out-of-order delivery and duplicate processing. Add idempotency keys before adding more workers.

The catch is that SMS is not a universal recovery channel. It can be unavailable to players without service, vulnerable to number recycling, and subject to regional delivery rules. It is not suitable as the only factor for high-value operator accounts. Keep a second recovery method, such as a previously enrolled authenticator or support-assisted proof, and make its audit trail comparable.

Do not use a longer code as a substitute for a better investigation trail. Longer codes affect usability and search space; they do not tell you which phase failed. Likewise, aggressive retries may improve a dashboard while creating duplicate messages and confusing the player.

I am not sure any single delivery metric can predict player success across every carrier and region. Your mileage will vary. The practical answer is to define a small set of phase-level indicators, test them with replay fixtures, and review samples with the people who handle recovery tickets.

The decision rule is simple: keep the design that can answer “what happened to this attempt?” without reconstructing it from five systems. At scale, spend engineering time on idempotency, ordering, retention, and redaction before adding another provider or client feature.

References

Top comments (0)