Voice AI

Building SMS Two-Factor Authentication with Durable Agents on Telnyx Edge

Every two-factor authentication flow has the same shape: generate a code, send it, check it, expire it. The SMS send is the easy part — it's a single API call. The hard part is everything around it: where the code lives, when it dies, who counts the attempts, and what happens when the process that issued the code is long gone. Most implementations answer those questions with a database table, a cron job, and a certain amount of hope.

This sample answers them with a single durable agent. sms-two-factor-agent is an Edge-based TypeScript agent that owns the entire lifecycle of an SMS verification code — generation, delivery over Telnyx SMS, verification, rate limiting, and automatic expiry — with no API keys in application code and no infrastructure to manage.

Direct answer, up front: the agent stores each code in Telnyx KV with a 300-second expirationTtl, schedules an expireCode cleanup task as a safety net, enforces 5 verification attempts per 5-minute window in durable per-phone state, and sends the SMS through the zero-credential [telnyx] binding. One actor per phone number. No cron, no locks, no secrets in code.

What the App Does

The application exposes two endpoints:

  • POST /verify with a phone number — the agent generates a 6-digit code, stores it, and sends it to the user via Telnyx SMS.
  • POST /check with the phone number and code — the agent verifies the submitted code against the stored value.

The behavioral details are where this sample earns its keep:

  • Codes expire after 300 seconds. Twice over, actually — more on that below.
  • Rate limiting is per phone number: 5 attempts per 5-minute window, enforced in durable state that survives across requests.
  • Successful verification clears the code and resets the counters. Failed attempts increment a fail counter.
  • DEMO_MODE is on by default, logging codes to the actor console instead of sending SMS — so you can exercise the entire flow, including expiry and rate limiting, without sending a single message.

How It Works

How does one agent handle many phone numbers at once?

It doesn't — and that's the point. There is one durable actor per phone number. The fetch handler routes each request to an actor keyed by the phone's E.164 number:

// src/index.ts — the fetch front door
const id = env.TwoFactorAgent.idFromName(phone); // one actor per phone
const stub = env.TwoFactorAgent.get(id);
return stub.fetch(request);

Every piece of state for a given phone number — the attempt counter, the rate-limit window — lives inside that one actor and is processed serially. There are no locks to take and no race conditions between a concurrent /verify and /check for the same number, because they physically cannot run at the same time. Different phone numbers get different actors, so they scale independently.

How does the agent send SMS without an API key?

Through the zero-credential [telnyx] binding. The binding is declared in telnyx.toml, and the platform injects credentials at runtime:

await this.env.TELNYX.messages.send({
  from: fromNumber,
  to: phone,
  text: `Your verification code is ${code}. It expires in 5 minutes.`,
});

Under the hood this hits Telnyx's POST /v2/messages endpoint, but application code never touches an API key. You still need a TELNYX_API_KEY for the telnyx-edge CLI and for the binding itself — but it's injected by the platform, not pasted into env files or hardcoded anywhere in the agent. For an authentication flow, keeping credentials out of application code isn't a nice-to-have.

How are codes stored and expired?

The code store is Telnyx KV with a time-to-live:

await this.env.KV.put(kvKey(phone), code, { expirationTtl: 300 });

One quirk worth knowing before you hit it: KV keys only allow a-z A-Z 0-9 - _ / = ., so the E.164 + gets stripped. For a placeholder like +1555XXXXXXX, the key becomes 2fa/1555XXXXXXX:

function kvKey(phone: string): string {
  return `2fa/${phone.replace("+", "")}`;
}

KV's TTL already expires the key on its own — so why does the agent also schedule a cleanup task?

this.schedule(300, "expireCode", { phone });

Belt and suspenders. The scheduled expireCode task is a deterministic hook for cleanup, counter resets, and audit logging — and it keeps working even if a future refactor swaps out the storage layer. Expiry by TTL is the mechanism; expiry by schedule is the contract.

How is the code verified?

Verification reads the stored code, and on success deletes it — making every code single-use:

const stored = await this.env.KV.get(kvKey(phone));
if (stored && stored === code) {
  await this.env.KV.delete(kvKey(phone)); // one-time use
  // ...reset attempt and fail counters
  return { ok: true };
}
// ...increment the fail counter
return { ok: false };

What's the env-var gotcha?

[env_vars] in telnyx.toml are injected into the function runtime's process.env only — the actor runtime has its own empty process.env. The fetch handler therefore passes DEMO_MODE and TELNYX_FROM_NUMBER into sendCode() explicitly. If you read those env vars directly inside the agent class, you'll silently get undefined. This is the single most common stumble when adapting the sample, and it's called out in the README for a reason.

Setup

Prerequisites:

  • Node.js 18+ and npm
  • Docker (compose plugin) — for telnyx-edge dev
  • A Telnyx account with an SMS-capable number (a 10DLC campaign is required for US A2P traffic)
  • The Telnyx Edge CLI, available from the edge-compute releases page

Clone and verify:

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/sms-two-factor-agent

export TELNYX_API_KEY=your_telnyx_api_key_here

npm install
npm run typecheck
npm test

Provision and ship:

# Create the StatefulActor function (prints a func_id for telnyx.toml)
telnyx-edge new-func --actor -l ts -n sms-two-factor-agent

# Provision the KV namespace for the codes
telnyx-edge storage kv create --name sms-two-factor-agent-2fa

# Ship to Telnyx Edge (~5-10 minutes: upload, build, deploy)
telnyx-edge ship

Run it — demo mode is on by default, so codes are logged to the actor console:

curl https://<your-function>.telnyxcompute.com/health

curl -X POST https://<your-function>.telnyxcompute.com/verify \
  -H "Content-Type: application/json" \
  -d '{"phone": "+1555XXXXXXX"}'

curl -X POST https://<your-function>.telnyxcompute.com/check \
  -H "Content-Type: application/json" \
  -d '{"phone": "+1555XXXXXXX", "code": "<CODE_FROM_LOGS>"}'

Go live: set DEMO_MODE = "false" in telnyx.toml under [env_vars], add an SMS-capable TELNYX_FROM_NUMBER (you can buy one via the Telnyx number API), and re-ship.

Conclusion

Ready to build with low-latency voice AI?

Join developers building the future of real-time conversations