DEV Community

Sonam
Sonam

Posted on

Build an SMS Two-Factor Authentication Agent at the Edge

Sending an SMS verification code takes one API call. Managing everything around that call is the real engineering problem.

A two-factor authentication flow needs to generate a code, limit repeated requests, store the code safely, reject bad attempts, expire it on time, and make it unusable after successful verification. A traditional implementation often adds a web server, database, cache, cleanup worker, and secrets-management layer before the first message is sent.

I built sms-two-factor-agent to explore a smaller architecture. It runs as a TypeScript agent on Telnyx Edge Compute and uses four primitives:

  • One durable actor per phone number
  • Telnyx KV with a five-minute TTL
  • Scheduled cleanup through the Agent SDK
  • SMS delivery through the native Telnyx binding

The result is a complete verification-code lifecycle behind two HTTP endpoints.

The API surface

The application exposes two main routes:

POST /verify   Generate and send a six-digit code
POST /check    Validate a submitted code
Enter fullscreen mode Exit fullscreen mode

A client starts verification with a placeholder E.164 number:

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

After receiving the code, the client submits it:

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

Successful verification deletes the stored code immediately, making it single-use.

Why use one actor per phone number?

The request handler derives an actor identity from the phone number and routes both operations to that same actor:

const id = env.AGENT.idFromName(actorNameFromPhone(phone));
const agent = env.AGENT.get(id);
Enter fullscreen mode Exit fullscreen mode

Each actor owns the verification state for one phone number, including request attempts, failed checks, and the current rate-limit window. Calls for that actor are serialized, which makes per-number state easier to reason about when requests arrive at nearly the same time.

Different phone numbers map to different actors, so their state remains isolated and they can be processed independently.

Expiring codes without a cleanup worker

The primary code store is Telnyx KV. Each generated code is written with a 300-second TTL:

await this.env.KV.put(key, code, {
  expirationTtl: 300,
});
Enter fullscreen mode Exit fullscreen mode

The key expires automatically after five minutes, so there is no table to sweep and no periodic cleanup job to maintain.

The agent also schedules an expiry task:

await this.schedule(300, "expireCode", { phone });
Enter fullscreen mode Exit fullscreen mode

That task provides a deterministic place to remove any remaining code data and reset the actor's counters. The current sample also falls back to durable actor storage if a KV write fails, recording an explicit expiration timestamp so the fallback copy cannot remain valid indefinitely.

Sending SMS through the Telnyx binding

In live mode, the actor sends the verification message through the Edge runtime's Telnyx binding:

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

The binding gives the deployed function a pre-authenticated Telnyx client. The application does not need to hardcode an API key or construct an authorization header for every message.

The sample starts in DEMO_MODE, where generated codes are written to the actor logs instead of being sent. That lets you test routing, expiry, verification, and rate limiting before connecting a real SMS-capable number.

Rate limiting belongs with the verification state

The actor allows five send attempts in a five-minute window. Because the counter is stored with the phone number's durable state, the limit survives ordinary process restarts and does not require a separate rate-limiting service.

Failed verification attempts are tracked separately. A correct code clears the code and resets the counters; an incorrect code decrements the remaining attempts returned to the client.

This is a useful property of the actor model: the behavior and the state it protects live together.

Running the example

Clone the code and install its dependencies:

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/sms-two-factor-agent
npm install
npm run typecheck
npm test
Enter fullscreen mode Exit fullscreen mode

Create the actor function and KV namespace with the Telnyx Edge CLI, place the returned identifiers in telnyx.toml, and deploy:

telnyx-edge new-func --actor -l ts -n sms-two-factor-agent
telnyx-edge storage kv create --name sms-two-factor-agent-2fa
telnyx-edge ship
Enter fullscreen mode Exit fullscreen mode

Keep demo mode enabled for the first run. When you are ready to send real messages, configure an SMS-capable Telnyx number, set DEMO_MODE to false, and deploy again. If you send application-to-person traffic to US numbers, make sure the sender and use case satisfy the applicable 10DLC requirements.

Where to take it next

This sample deliberately focuses on the verification lifecycle rather than a complete identity system. Before using the pattern in production, consider adding hashed codes, stronger abuse controls, audit events, delivery-status handling, idempotency, tenant isolation, and a recovery path.

The broader pattern also applies beyond 2FA. Per-recipient actors, expiring state, scheduled work, and native communications bindings are useful for passwordless login, account recovery, consent confirmation, time-sensitive alerts, and other workflows where state and messaging must move together.

Resources

How would you extend this pattern: stricter risk scoring, multiple delivery channels, or a full passwordless sign-in flow?

Top comments (0)