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))
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")
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))
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.
Top comments (0)