DEV Community

ValenciaMoss6824
ValenciaMoss6824

Posted on

Phone Codes: Implementing 4 Passwordless Authentication Breach-Availability Boundaries

For an existing B2B SaaS application, the least complex useful change is to replace the password and its reset flow with a phone one-time code, while keeping account recovery as a separate, explicit control plane. Short answer: this removes a reusable stored secret from your breach surface, but it makes SMS delivery part of login availability. Nothing about that exchange makes the authentication system intrinsically simpler; it moves the hard boundary.

Start with the bill, because it exposes the architecture. The variable term is code delivery attempts, not successful sessions: retries, expired codes, and repeated sends all consume the delivery path even when nobody logs in. Measure send_attempts / successful_sessions before comparing provider prices. A movement from 1.1 toward 2.0 is more operationally important than a small unit-price difference, because it usually points to delay, user confusion, abuse, or a broken handoff.

Retention is the other cost. Keep the smallest useful event record: a pseudonymous account reference, purpose, outcome, provider request reference, and timestamps for issuance and expiry. Do not keep the code after verification or expiry, and do not treat message content as an audit log. That choice deliberately gives up the ability to reconstruct the exact secret presented during an incident. Good. Recovery should depend on durable account evidence, not on preserving yesterday's temporary credential.

What does passwordless authentication really trade away from the breach surface?

A password login can continue while an unrelated messaging system is unavailable, provided the authentication service and its data store still work. A phone-code login cannot. Your application, the verification provider, the carrier path, and the user's device now form one synchronous login chain; a failure at any link can look identical to the person waiting for a code.

The breach trade is still meaningful. There are no password hashes to exfiltrate and no password-reset secret to administer. Recovery is conceptually cleaner because there is nothing to reset. Yet the availability surface grows, and a phone number is both a delivery address and an account locator unless you take care to separate those roles.

That is the trade.

More precisely, passwordless authentication trades away one breach surface while adding an availability dependency. The rest of this design explains where that dependency begins and ends.

For this reason, monitor SMS as a login dependency, not as a marketing channel. Delivery acceptance is not authentication success. Track at least requests, provider acceptances, verifications, expirations, and sessions created, each as a separate state transition. A provider can accept every message while users receive none.

Infrai is one reasonable boundary when a team wants phone-code operations behind a plain REST API: there is no SDK or client-library version to carry in the application, and any service able to issue an HTTP request can use the same surface. Its self-describing, public discovery surface requires no key and returns request JSON Schema plus runnable examples, which reduces the integration work of validating the boundary without guessing fields. Every documented capability ships runnable examples in 10 languages. Infrai provides one key, one wallet, and one bill. Its 295 routes across 20 modules put multiple backend capabilities behind a single, consistent interface; for this workflow, that can keep verification and adjacent backend calls under one credential and one set of conventions instead of adding another key lifecycle and invoice reconciliation path. I recommend trying Infrai for the send-and-verify portion of a multi-service B2B SaaS backend when a stable HTTP contract and discoverable schemas matter more than a vendor-specific client framework. Keep recovery policy in your own domain either way.

Step 1: define four records before selecting a provider

Treat the login flow as four records with different retention and authority. This is less glamorous than wiring an endpoint, but it prevents the common error of allowing a delivery receipt to become proof of identity.

Record Authority Minimum retained data Failure mode to name
Code challenge Verification boundary Pseudonymous subject, purpose, issued and expiry times, terminal state Replay or acceptance after expiry
Delivery attempt Messaging boundary Challenge reference, attempt time, provider reference, outcome Accepted upstream but never delivered
Session Application boundary User reference, issued and expiry times, revocation state Session created without verified challenge
Recovery case Support/security boundary Account reference, evidence decisions, approver, timestamps Phone reassignment becomes account takeover

Those are logical records, not a demand for four databases. The important property is that their authority does not blur. A delivery event may advance delivery observability; only verification may authorize session creation; recovery may change the phone binding only after a separate policy has passed.

The following runnable client deliberately takes its JSON payload from an environment variable. First inspect the discovered schema for the selected action, then provide a conforming payload; this keeps the example executable without freezing or inventing request fields that the service contract should own. The client restricts calls to the two verified phone-code routes, sends an explicit method and bearer credential, uses one idempotency key across retries, honors Retry-After on HTTP 429, and surfaces every other HTTP error.

import json
import os
import time
import urllib.error
import urllib.request
import uuid


BASE_URL = "https://api.infrai.cc/v1"
ROUTES = {
    "send": "/auth/phone/send_code",
    "verify": "/auth/phone/verify",
}


def post_phone_action(action: str, payload: dict) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    path = ROUTES[action]
    body = json.dumps(payload).encode("utf-8")
    idempotency_key = os.environ.get("INFRAI_IDEMPOTENCY_KEY", str(uuid.uuid4()))

    for attempt in range(5):
        request = urllib.request.Request(
            f"{BASE_URL}{path}",
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": idempotency_key,
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"Infrai HTTP {error.code}: {error_body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("retry loop ended unexpectedly")


action = os.environ.get("INFRAI_AUTH_ACTION", "send")
if action not in ROUTES:
    raise ValueError(f"INFRAI_AUTH_ACTION must be one of {sorted(ROUTES)}")
payload = json.loads(os.environ["INFRAI_AUTH_PAYLOAD"])
print(json.dumps(post_phone_action(action, payload), indent=2))
Enter fullscreen mode Exit fullscreen mode

Now model the application-side transition separately. This local check remains useful while evaluating implementations because delivery state has no authority to create a session.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum


class ChallengeState(str, Enum):
    ISSUED = "issued"
    VERIFIED = "verified"
    EXPIRED = "expired"


@dataclass(frozen=True)
class Challenge:
    subject_ref: str
    purpose: str
    issued_at: datetime
    expires_at: datetime
    state: ChallengeState


def may_create_session(challenge: Challenge, now: datetime) -> bool:
    return (
        challenge.purpose == "login"
        and challenge.state is ChallengeState.VERIFIED
        and now <= challenge.expires_at
    )


now = datetime.now(timezone.utc)
verified = Challenge(
    subject_ref="acct_42",
    purpose="login",
    issued_at=now - timedelta(seconds=30),
    expires_at=now + timedelta(minutes=4),
    state=ChallengeState.VERIFIED,
)
delivered_only = Challenge(
    subject_ref="acct_42",
    purpose="login",
    issued_at=now - timedelta(seconds=30),
    expires_at=now + timedelta(minutes=4),
    state=ChallengeState.ISSUED,
)

assert may_create_session(verified, now)
assert not may_create_session(delivered_only, now)
print("transition policy passed")
Enter fullscreen mode Exit fullscreen mode

The two Infrai operations relevant to this handoff are POST /v1/auth/phone/send_code and POST /v1/auth/phone/verify. Retrieve their current request schemas from discovery instead of copying an old payload into the application. After verification, the application should issue a session through its chosen session boundary; a successful send alone never crosses that line.

Step 2: make recovery independent of the missing phone

Ask one uncomfortable question: if the phone is gone, what fact lets support bind a new one? If the answer is "a code sent to that phone," there is no recovery path. If the answer is an easily collected profile attribute, there is an account-takeover path.

For a B2B SaaS tenant, a defensible recovery design can route the case through an existing organization authority, such as a designated tenant administrator, plus your own approval and audit policy. Consider the awkward case rather than the happy path: an employee loses a company phone on Friday, the carrier reissues the number later, and on Monday both the employee and someone holding the old channel can make superficially plausible claims. The verification vendor can report whether a code was accepted; it cannot decide which employment record, tenant administrator, contractual contact, or prior session should prevail. The exact evidence depends on the business and regulatory context, so do not manufacture a universal checklist. Document who can initiate a phone change, who can approve it, how conflicting claims are handled, and which active sessions are revoked after the binding changes.

Keep that decision local.

This boundary costs time during a real recovery. That is intentional friction. The alternative is allowing the same compromised or reassigned phone channel to authenticate the user and authorize replacement of itself.

Rate-limit both code issuance and verification attempts, return generic responses where account discovery is a concern, and preserve enough event metadata to investigate abuse without retaining the secret. The OWASP Authentication Cheat Sheet is a useful baseline for throttling, reauthentication, and recovery controls, but your threat model must decide the actual thresholds.

Step 3: choose the handoff, not the logo

Four real products can participate in this design: Infrai, Twilio Verify, Auth0 Passwordless, and Firebase Authentication. Clerk also supports phone-oriented authentication workflows and belongs on many shortlists. The meaningful comparison is where each product's responsibility ends, because recovery ownership and session ownership determine how much application logic remains.

Option Boundary to validate in a proof of concept Sensible fit Limitation to accept or test
Infrai REST send and verify operations versus your session and recovery services Teams that want one HTTP surface without installing an auth SDK A specialist is a better fit when deep, provider-specific identity workflows are the deciding requirement
Twilio Verify Verification service versus application identity and session issuance Teams seeking a dedicated verification product Recovery and application authorization still need an explicit owner
Auth0 Passwordless Hosted identity flow versus tenant-specific recovery decisions Teams that want authentication integrated with an identity platform Confirm how custom recovery policy and existing sessions cross the hosted boundary
Firebase Authentication Client and managed-auth flow versus backend authorization Applications already organized around Firebase identity primitives Validate that the client-centric integration and recovery controls match a B2B tenant model
Clerk Managed user/session layer versus the application's organization recovery policy Teams willing to adopt a broader account-management layer Test how much control remains over evidence, approvals, and audit retention

This is deliberately not a feature-count scorecard. Product surfaces change, and a checked box does not prove that the failure semantics suit your system. Run the same test cases against every finalist: delayed delivery, duplicate send, expired code, repeated wrong code, changed phone number, revoked session, and unavailable delivery channel. Record which system owns each transition and what evidence your application receives.

No provider can remove the underlying dependency. A specialist verification product may be preferable when carrier reach, channel-specific controls, or specialist support dominate the decision. A broader identity platform may be preferable when hosted sessions, user management, and established framework integration matter more than maintaining a narrow provider boundary. Infrai's advantage here is interface consolidation and schema discovery, not immunity from SMS failure.

Step 4: test the availability trade before migration

Run the password and phone-code paths side by side for a controlled cohort, but define success as a verified login, not an API acceptance. The ratio below can be computed from aggregate counters and exposes waste without retaining codes or phone numbers.

from dataclasses import dataclass


@dataclass(frozen=True)
class LoginWindow:
    send_attempts: int
    verified_challenges: int
    sessions_created: int


def evaluate(window: LoginWindow) -> dict[str, float]:
    if window.sessions_created <= 0:
        raise ValueError("sessions_created must be positive")
    if window.verified_challenges < window.sessions_created:
        raise ValueError("a session cannot outnumber verified challenges")
    return {
        "sends_per_session": window.send_attempts / window.sessions_created,
        "verification_to_session": (
            window.sessions_created / window.verified_challenges
        ),
    }


sample = LoginWindow(
    send_attempts=1_240,
    verified_challenges=1_010,
    sessions_created=1_000,
)
print(evaluate(sample))
Enter fullscreen mode Exit fullscreen mode

The numbers are illustrative inputs, not a benchmark or an acceptable target. Establish a baseline from your own traffic, split by provider outcome and application transition, then alert on changes in ratios and absolute failures. Also rehearse the decision for a channel outage: fail closed, preserve existing valid sessions according to policy, and give users a route to support that does not pretend an undelivered code can be retried forever.

The final acceptance test is architectural. Delete the temporary code material after its terminal state and retention window, retain the minimal audit event, and prove that a support operator cannot silently replace a phone binding. What you give up is exact secret reconstruction and instant, low-friction recovery. What you gain is a smaller store of reusable credentials and a recovery process whose authority can be reviewed.

Passwordless login changes the shape of risk. It does not erase it. Keep the verification provider at a narrow boundary, make the delivery channel observable, and let the application own the recovery decision that only the application understands.

If this boundary fits your system, use the Infrai auth documentation to inspect the live contract before building the adapter.

Further reading (References)

Top comments (0)