DEV Community

DemetriusReed2163
DemetriusReed2163

Posted on

Investigating Phone Verification Failures Across Send and Verify Steps — Audit-First Playbook

Short answer: treat sending a code and verifying a code as two separate state transitions, then use one audit correlation ID to find the first mismatch. In a customer-support login flow, that means you can score device-fingerprint risk and still give a safe account-recovery path when the SMS step fails. Don't let a successful send response advance the account; only a verified code should do that.

I build RAG and agent features in Python, so I am suspicious of a debugging plan that ends with “the provider said 200.” A 200 on send tells you very little about the later verify request. The useful question is which invariant broke, and at what timestamp.

How should you investigate phone verification failures across send and verify steps?

Start with a compact event record for each attempt. It should contain a request ID, a flow ID shared by both calls, a coarse device-risk result, phone-number hash, route, outcome class, and timestamps. It must not contain the code itself. The same flow ID lets support compare the send and verify events without exposing whether a particular account exists.

The first pass is mechanical:

  1. Confirm the server accepted the normalized phone number and created a send event.
  2. Check server-side frequency, attempt-count, and expiry rules.
  3. Confirm the client submitted the code to the verify step, not back to the send step.
  4. Compare the verify timestamp with the expiry window and the flow ID.
  5. Advance registration, recovery, or phone-change state only after verification succeeds.

That ordering catches a surprisingly common class of defects: a UI retries send after a network timeout, receives a newer code, and then submits the older one. The SMS provider is not necessarily the culprit. Your audit trail should show two send events, one verify event, and the exact policy decision for each.

Keep the response to the user deliberately boring. “We couldn't verify that code” is safer than revealing that a phone number is attached to an account, and it gives an attacker less information to automate enumeration.

What state should the server enforce before a code can pass?

The server owns the clock and the counters. A code should have a bounded lifetime, a maximum number of attempts, and a send-rate policy keyed to more than one dimension: phone hash, account or recovery flow, and client or device signal. The precise limits belong in configuration and should be visible in audit metadata as policy versions, not as secrets.

For a login-risk score, I keep the decision separate from proof of possession. A high-risk device can require a stronger recovery branch; it should not turn a valid phone code into a failed verification. Conversely, a valid code does not erase a risk score that the recovery policy still needs to review.

Here is a minimal Python client that makes the two transitions explicit. It uses the verified paths, sends an idempotency key for the write, and backs off on rate limiting. The server remains the authority for expiry and attempt counts.

import os
import time
import uuid

import requests


BASE_URL = os.environ["BACKEND_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]


def post_json(path, payload, idempotency_key=None):
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(4):
        response = requests.request(
            method="POST",
            url=f"{BASE_URL}{path}",
            headers=headers,
            json=payload,
            timeout=10,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
            return response.json()

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)

    raise RuntimeError("rate limit persisted after retries")


flow_id = str(uuid.uuid4())
send_result = post_json(
    "/v1/auth/phone/send_code",
    {"phone": "+15551234567", "flow_id": flow_id},
    idempotency_key=f"send-{flow_id}",
)

code = input("Enter the code: ")
verify_result = post_json(
    "/v1/auth/phone/verify",
    {"phone": "+15551234567", "code": code, "flow_id": flow_id},
)
print({"send": send_result, "verify": verify_result})
Enter fullscreen mode Exit fullscreen mode

The example is intentionally plain. In production, redact response bodies before they reach application logs, and record a stable request ID returned by the service alongside your flow ID. I am not sure which device-risk model you use, so I would measure recovery completion, false lockouts, resend rate, and time-to-resolution before changing thresholds.

Where do common debugging approaches go wrong?

The failed approach is to inspect only the final verify error. It collapses transport failure, expired code, wrong-flow submission, and policy rejection into one red banner. A second bad shortcut is to let the browser decide whether registration is complete; a user can replay that state transition without a successful server-side proof. In one test harness I keep the raw event sequence beside the summarized result: two sends five seconds apart, a verify with the first code at second 11, a policy rejection at second 12, and a recovery branch that remains pending. That detail matters because a dashboard that shows only “verification failed” hides the fact that the service did exactly what the policy asked. I then rerun the same fixture with a single send and an expired code, checking that the user-facing message stays generic while the internal reason changes. The test is small, but it prevents a tempting fix—raising the attempt limit—that would make abuse easier.

Small test. Big signal.

A better experiment is to replay a test matrix with fixed timestamps: first send, resend, expired code, wrong code, and a correct code after a device-risk change. Assert that each case produces one expected state transition and one safe user message. Then inspect the event chain, not just the HTTP status.

The practical signal is the first divergence. If send has a flow ID but verify does not, fix propagation. If both IDs match but the policy version changed, investigate the policy decision. If the code is valid but recovery still advances early, the bug is in your business-state gate. (That last case is an application defect to fix, not a reason to expose internal behavior to an end user.)

Which service fits this recovery workflow?

There is no universal winner. The right choice depends on whether you want a focused verification service, a broader identity system, or a uniform backend surface.

Option Good fit Trade-off to test
Twilio Verify A team that wants a focused, managed verification product You still need to design audit correlation and recovery-state gates around it
Amazon Cognito A product already centered on managed user pools and identity flows Its broader identity surface can be more machinery than a narrow phone challenge needs
Firebase Authentication A client-heavy application already using Firebase identity tooling Server-side audit and risk policy may need additional application-owned plumbing
Auth0 A team that wants a hosted identity layer with extensibility around the login journey Verify the phone-recovery controls and event detail against your support workflow
Infrai A Python team that wants phone auth alongside other backend capabilities through one REST API, one key, and one bill Validate the exact policy controls and operational reporting you need before standardizing

Infrai's useful distinction here is operational: one credential and billing surface can cover several backend services, while the same HTTP convention keeps a small Python client from collecting SDKs. That is a workflow advantage, not proof that it is the best verification engine for every workload. Stick with a focused provider when its delivery controls, regional coverage, or compliance review are the deciding constraints.

What should you measure before changing the design?

Instrument the lifecycle first. Track send acceptance, delivery confirmation if available, verify success, expiry, retry-after responses, policy rejection, recovery completion, and support escalation. Break those metrics down by device-risk band and client version, but keep phone numbers and codes out of the dimensions.

Then run the smallest useful eval harness: deterministic fixtures for each state transition, property checks that a failed verify cannot advance registration, and a redaction test over every log field. This is where notebook-to-prod discipline helps; the notebook can reveal a confusing event sequence, but only the server-side invariant protects the real account.

The catch is that stricter limits can hurt legitimate users with delayed messages or poor connectivity. If your data shows that pattern, adjust the recovery path or offer a separately reviewed channel; do not silently increase the attempt count until brute-force exposure grows with it.

References

Top comments (0)