How to add two-factor SMS verification to Webflow with Twilio

Learn how to add two-factor SMS verification to Webflow with Twilio Verify, sending and checking one-time codes from a Webflow Cloud Route Handler.

How to add two-factor SMS verification to Webflow with Twilio

A password, on its own, is one leaked credential away from an account takeover, and adding a Twilio SMS code to Webflow Cloud gives every sensitive action a second lock that lives on the server rather than in the browser.

Two-factor verification is a server job pretending to be a form field. The code the user types is easy; the hard part is generating it, sending it, checking it, and enforcing expiry and attempt limits without ever trusting the browser.

Webflow Cloud runs your app as a Cloudflare Worker, the server-side component that makes this possible. Your Twilio credentials live in environment variables, the code is sent and checked from Route Handlers, and the result comes back to the browser as a plain yes or no.

Twilio's Verify API does the heavy lifting: it handles the code, its 10-minute lifetime, and the attempt ceiling, so you never store or compare the raw code yourself.

There is one runtime detail that shapes the whole build. Twilio's Node helper library pulls in node:net and does not run on the Workers runtime, so every Twilio call here is a plain fetch against the Verify REST API. That is the approach Cloudflare documents for calling Twilio from a Worker, and it keeps the integration dependency-free.

In this guide, we build the full flow in six steps, from a Verify service and a Workers-compatible client through sending a code, checking it, issuing a signed session, and wiring the two-step UI.

What do you need to add two-factor SMS verification to Webflow with Twilio?

You need a Webflow Cloud Next.js app and a Twilio account with a Verify service. The Verify service is the piece that matters most because it generates and validates codes, so your own code never has to.

Here is the full list of what you need:

  • A Webflow Cloud project running a Next.js app, deployed or in local dev
  • A Twilio account with an active Verify service (console.twilio.com)
  • Your Twilio Account SID and Auth Token
  • Your Verify Service SID (starts with VA)
  • A server secret for signing the post-verification session
  • An existing signed-in session that already knows the user's phone number, since the second factor is checked against the number on their account rather than one typed into the form
  • A Key Value binding named SESSION_KV for the server-side send limit
  • Node.js 22.13.0 or higher locally, the minimum for Webflow CLI 2.0

If you already handle sign-in through Auth0 authentication on a Webflow site, two-factor sits on top of it: the user authenticates first, then clears the SMS step before reaching anything sensitive.

And if your app already sends outbound SMS the way a transactional SMS setup on Webflow Cloud does, you are reusing the same Twilio account, just a different API.

6 steps to add two-factor SMS verification to Webflow with Twilio

The flow moves the way the user experiences it: set up the service, connect to it, send a code, check the code, remember that it passed, then put a form on the page. Steps one and two are the foundation. Steps three through six are the verification loop.

Here is the sequence I follow on every Webflow Cloud project that gates an action behind SMS:

  • Create a Twilio Verify service and set your credentials
  • Build a Twilio Verify client that runs on the Workers runtime
  • Send the code from a Route Handler
  • Check the code from a Route Handler
  • Issue a signed verification session with Web Crypto
  • Build the two-step verification UI

Every Twilio call runs in a Route Handler, so your Account SID and Auth Token stay server-side and never reach the browser.

1. Create a Twilio Verify service and set your credentials

Twilio Verify is keyed to a service, a container that holds your code settings and gives you a Service SID starting with VA. Create it first because every send-and-check call routes through it.

In the Twilio Console, open Verify, create a new service, give it a friendly name (up to 30 characters, and it appears in the body of the verification message rather than as the sender), and copy the Service SID. Your Account SID and Auth Token are on the Console dashboard.

Add all three to .env.local and to your Webflow Cloud project under Environment Variables, marking the Auth Token and session secret as Secrets so they stay encrypted:

TWILIO_ACCOUNT_SID=your_account_sid
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_VERIFY_SERVICE_SID=your_verify_service_sid
SESSION_SECRET=a_long_random_string

None of these gets a NEXT_PUBLIC_ prefix. The browser never touches them; only Route Handlers read them from process.env, which is where Webflow Cloud injects environment variables and Secrets at runtime.

2. Build a Twilio Verify client that runs on the Workers runtime

Because the Twilio Node library does not run on Workers, wrap the REST call in a small utility rather than repeating the auth header in every handler. Twilio Verify authenticates using HTTP Basic auth with your Account SID and Auth Token, and its endpoints accept form-encoded bodies.

Create lib/twilio.ts:

// lib/twilio.ts

const VERIFY_BASE = 'https://verify.twilio.com/v2'

// The Twilio Node helper pulls in node:net and fails on the Workers runtime,
// so every call is a fetch against the Verify REST API with Basic auth.
export async function verifyFetch(
  path: string,
  params: Record<string, string>
): Promise<Response> {
  const auth = btoa(
    `${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
  )

  return fetch(
    `${VERIFY_BASE}/Services/${process.env.TWILIO_VERIFY_SERVICE_SID}${path}`,
    {
      method: 'POST',
      headers: {
        Authorization: `Basic ${auth}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams(params),
    }
  )
}

Two details do the real work. The body is application/x-www-form-urlencoded built with URLSearchParams, because that is what the Verify endpoints expect; sending JSON is the most common reason a first call fails.

And btoa builds the Basic auth header from your credentials, so token handling lives in exactly one place and no handler ever sees the raw Auth Token.

3. Send the code from a Route Handler

The first half of the flow sends a one-time code to the user's phone. Twilio Verify generates the code, delivers the SMS, and returns a pending status. You never see the code, which is the point.

Where that phone number comes from decides whether any of this is worth doing. If the handler takes it from the request body, an attacker submits their own number, passes the check, and walks away with a token your app treats as proof. So the number comes from the signed-in session, and the handler reads nothing from the body at all. A helper that resolves the current user is the smallest piece of that puzzle:

Create lib/auth.ts, then the send handler:

// lib/auth.ts
import { cookies } from 'next/headers'
import { verifyToken } from '@/lib/session'

// Whatever your login flow already sets. The payload carries the user id and the
// phone number on record, both written server-side at sign-in, so a caller can
// never substitute a different number later in the flow.
export type AppUser = { userId: string; phone: string }

export async function requireUser(): Promise<AppUser | null> {
  const jar = await cookies()
  const token = jar.get('app_session')?.value
  if (!token) return null

  const payload = await verifyToken(token)
  if (!payload) return null

  const [userId, phone] = payload.split('|')
  return userId && phone ? { userId, phone } : null
}

Then the send handler itself:

// app/api/2fa/start/route.ts
import { NextResponse } from 'next/server'
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { verifyFetch } from '@/lib/twilio'
import { requireUser } from '@/lib/auth'

const MAX_SENDS = 3
const WINDOW_SECONDS = 15 * 60

export async function POST() {
  // Nothing is read from the request body. The number comes from the signed
  // session, so a caller cannot start a verification for someone else's phone.
  const user = await requireUser()
  if (!user) {
    return NextResponse.json({ error: 'Sign in first' }, { status: 401 })
  }

  // Server-side send cap, keyed to the user. A throttled button is not a limit,
  // because anyone can call this route directly.
  const { env } = getCloudflareContext()
  const bucket = `2fa:sends:${user.userId}`
  const used = Number((await env.SESSION_KV.get(bucket)) ?? '0')
  if (used >= MAX_SENDS) {
    return NextResponse.json({ error: 'Too many code requests' }, { status: 429 })
  }
  await env.SESSION_KV.put(bucket, String(used + 1), {
    expirationTtl: WINDOW_SECONDS,
  })

  const res = await verifyFetch('/Verifications', {
    To: user.phone,
    Channel: 'sms',
  })

  if (!res.ok) {
    return NextResponse.json({ error: 'Could not send code' }, { status: 502 })
  }

  const { status } = (await res.json()) as { status: string }
  return NextResponse.json({ status }) // "pending" once the SMS is on its way
}

Because the number is read from the session rather than the request, there is no user input to validate here; store it in E.164 form at sign-up and the send call is always well formed. The send cap is the part that needs your attention. Twilio Verify caps verification attempts to the same number and runs Fraud Guard by default, but that protects one destination, not your handler. That is why the handler above requires a session and counts sends per user in the Key Value store before it calls Twilio. Without a server-side cap, an attacker can walk an open handler across thousands of numbers and bill every message to you.

4. Check the code from a Route Handler

The second half validates the user's input. You post the phone and the code to the VerificationCheck endpoint, and Twilio returns approved when they match. A verification disappears once it is approved or once it expires. The same happens when it runs out of check attempts, and the endpoint then answers with a 404 rather than a status, so handle that as its own case.

Create app/api/2fa/check/route.ts:

// app/api/2fa/check/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { verifyFetch } from '@/lib/twilio'
import { signToken } from '@/lib/session'
import { requireUser } from '@/lib/auth'

export async function POST(request: NextRequest) {
  const user = await requireUser()
  if (!user) {
    return NextResponse.json({ error: 'Sign in first' }, { status: 401 })
  }

  // Only the code comes from the browser. The number is the one on the session.
  const { code } = (await request.json()) as { code: string }

  const res = await verifyFetch('/VerificationCheck', {
    To: user.phone,
    Code: code,
  })

  // A consumed, expired, or over-attempted verification returns 404.
  if (res.status === 404) {
    return NextResponse.json(
      { approved: false, reason: 'expired' },
      { status: 410 }
    )
  }

  const { status } = (await res.json()) as { status: string }
  if (status !== 'approved') {
    return NextResponse.json({ approved: false }, { status: 401 })
  }

  // The token names the user who cleared the second factor, so a protected route
  // can compare it against the session it already has.
  const token = await signToken(`2fa:${user.userId}`, 15 * 60)

  const response = NextResponse.json({ approved: true })
  response.cookies.set('2fa', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 15 * 60,
  })
  return response
}

The code is never compared in your own code; Twilio owns that check, which is what keeps expiry and attempt limits honest. The only thing you persist is the result, and you persist it as a signed token in the next step, so a later request cannot simply claim it passed.

5. Issue a signed verification session with Web Crypto

A cookie that just says 2fa=true is trivial to forge, and a token bound to a phone number the browser supplied is barely better. The token below names the user who cleared the factor, so a protected route can hold it against the session it already has. Instead, sign a small payload with a server secret so only your code can mint a valid token, and verify it on any route that requires a cleared second factor.

On Webflow Cloud, signing uses the Web Crypto API, which Webflow's Node.js compatibility docs recommend over Node's crypto for hashing and signing.

Create lib/session.ts:

// lib/session.ts

async function key(): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(process.env.SESSION_SECRET!),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign', 'verify']
  )
}

function toHex(buf: ArrayBuffer): string {
  return Array.from(new Uint8Array(buf))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')
}

// Sign "payload.expiry" with HMAC-SHA256 so the token cannot be forged.
export async function signToken(
  payload: string,
  ttlSeconds: number
): Promise<string> {
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds
  const body = `${payload}.${exp}`
  const sig = await crypto.subtle.sign(
    'HMAC',
    await key(),
    new TextEncoder().encode(body)
  )
  return `${body}.${toHex(sig)}`
}

// Verify the token and its expiry, then hand back the payload it carried.
export async function verifyToken(token: string): Promise<string | null> {
  const cut = token.lastIndexOf('.')
  if (cut < 0) return null
  const body = token.slice(0, cut)
  const sig = token.slice(cut + 1)

  const expAt = body.lastIndexOf('.')
  const exp = Number(body.slice(expAt + 1))
  if (!exp || exp < Math.floor(Date.now() / 1000)) return null

  const expected = await crypto.subtle.sign(
    'HMAC',
    await key(),
    new TextEncoder().encode(body)
  )

  // Constant-time compare so the signature cannot be probed byte by byte.
  const want = toHex(expected)
  if (want.length !== sig.length) return null
  let diff = 0
  for (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ sig.charCodeAt(i)
  if (diff !== 0) return null

  return body.slice(0, expAt)
}

Read the 2fa cookie and call verifyToken at the top of any protected Route Handler. Reject the request if it returns null, and reject it just as firmly if the payload is not 2fa: followed by the id of the user the request is already authenticated as. A valid signature only proves that some user cleared the factor; the id is what proves it was this one. Because the token carries its own expiry and a signature, a stale or tampered cookie fails the check without a database lookup.

6. Build the two-step verification UI

The front end is two states in one Client Component: request a code, then enter it. There is no phone field, because the server already knows the number and accepting one here would hand the decision back to the browser. The widget only calls your own Route Handlers, so no Twilio credentials or codes ever reach it.

Create app/components/TwoFactor.tsx:

// app/components/TwoFactor.tsx
"use client";

import { useState } from "react";

export default function TwoFactor({ baseUrl = "" }: { baseUrl?: string }) {
  const [code, setCode] = useState("");
  const [sent, setSent] = useState(false);
  const [message, setMessage] = useState("");

  async function sendCode() {
    // No phone field. The server sends to the number on the signed-in account.
    const res = await fetch(`${baseUrl}/api/2fa/start`, { method: "POST" });
    setSent(res.ok);
    setMessage(
      res.ok
        ? "We sent a code to the number on your account."
        : "Could not send a code right now."
    );
  }

  async function checkCode() {
    const res = await fetch(`${baseUrl}/api/2fa/check`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ code }),
    });
    setMessage(res.ok ? "Verified." : "Wrong or expired code.");
  }

  return (
    <div>
      {!sent ? (
        <button onClick={sendCode}>Send code</button>
      ) : (
        <>
          <input
            value={code}
            onChange={(e) => setCode(e.target.value)}
            placeholder="123456"
            inputMode="numeric"
          />
          <button onClick={checkCode}>Verify</button>
        </>
      )}
      <p>{message}</p>
    </div>
  );
}

Pass baseUrl from your app's basePath, because a Webflow Cloud app mounts at a path such as /app, so a bare /api/2fa/start would resolve against the wrong root. With the component mounted, the user enters a phone, receives a text, types the code, and the signed cookie from step five carries that cleared status into the rest of your app.

What breaks Twilio SMS verification on Webflow Cloud?

Most failures trace back to a few predictable causes:

  • The Twilio Node library is imported into a Route Handler
  • Basic auth built from the wrong credentials
  • A code that has expired or run out of attempts
  • A phone number that is not in E.164 format

Each one tends to fail with a generic error, so the symptoms below map them to a cause.

The build fails, or the handler throws on any Twilio import

The Twilio Node helper library was imported, but it depends on node:net and other Node internals that the Workers runtime does not provide, so it fails at build time or on the first request.

Remove the twilio package from your handlers and call the Verify REST API with fetch, as the lib/twilio.ts utility does. Everything Verify needs, sending and checking codes, is a plain HTTPS POST, so no SDK is required on the edge.

Every Twilio call returns 401 Unauthorized

The Basic auth header is built from the wrong values. Verify authenticates with your Account SID and Auth Token, not the Verify Service SID, which belongs in the URL path instead.

Confirm that TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN match the Console dashboard, that the Service SID starting with VA is used only in the path, and that all three are set for the deployed environment, not just in a local .env file. Re-copy the Auth Token if it was rotated, since the old value stops working immediately.

VerificationCheck returns 404 instead of a status

The verification no longer exists. Twilio deletes the verification once it is approved, once it expires after 10 minutes, or once it runs out of check attempts. Later checks against it return 404.

Treat 404 as an expired-or-used case in your handler, as the check route does, and prompt the user to request a new code rather than retrying the old one. If users routinely run out of time, shorten the round trip by autofilling the code field when the browser supports SMS one-time code autocomplete.

The SMS never arrives, or the send is rejected

The number is not in E.164 format, or it is a type that Twilio cannot reach on your account. Verify expects a leading + and country code, such as +14155552671, and rejects anything else with a 400.

Validate the format before calling Twilio, surface Twilio's error to the user when a send fails, and wrap the send and check handlers in Sentry error tracking on a Webflow Cloud app so a spike in failed deliveries shows up with a stack trace instead of as a silent drop-off.

Extend SMS verification across your Webflow Cloud app

The setup in this guide gives you a signed, server-verified second factor driven from the edge. From there, the natural extensions reuse the same Route Handlers and the same signed-session pattern.

Step-up verification is the most common next build. Instead of gating login alone, call the same start and check handlers before a high-risk action such as a payout or a password change, and require a fresh signed token scoped to that action. The signed-session helper already provides the primitive; you just shorten its lifetime and check it at the sensitive route.

From there, persisting a user's verified phone and their verification history pairs naturally with the data layer behind a serverless database such as Neon on Webflow Cloud.

Explore Webflow + Twilio to see how SMS and voice connect to the rest of your site, from a Code Embed on a static page to the API-driven verification flow in this guide.

Frequently asked questions

Should I use Twilio Verify or send codes myself using the Messaging API?

Use Twilio Verify for two-factor. It generates the code, sends it, enforces a ten-minute expiry and an attempt ceiling, and rate-limits sends, all of which you would otherwise build and store yourself with the raw Messaging API. Verify keeps your handlers to two calls, start and check, and means no code is ever stored in your own database.

Does the Twilio Node library work on Webflow Cloud's Workers runtime?

No, and that is fine. The Twilio Node helper depends on node:net and does not run on the Workers runtime behind Webflow Cloud, which is why this guide calls the Verify REST API with fetch. Sending and checking a code are each a single authenticated POST, so you get the full flow without any SDK.

Where do I store the SMS code?

You do not store it anywhere. Twilio Verify stores the code and validates it during the VerificationCheck call, so your app never sees or compares the raw code. The only thing you persist is the outcome, and this guide persists it as a short-lived HMAC-signed cookie rather than the code itself.

How long is a code valid, and what happens after too many attempts?

A Twilio Verify code is valid for ten minutes, after which Twilio discards it. Once it expires, is approved, or reaches its maximum number of check attempts, Twilio deletes the verification, and subsequent checks return a 404. Handle that case by asking the user to request a fresh code instead of retrying the old one.

Can I add this to a regular Webflow site instead of a Webflow Cloud app?

The verification form can live on a standard Webflow page, but the sending and checking must run on a server, and so must the token signing, which is what Webflow Cloud provides. The page calls your Route Handlers over fetch, and the credentials and the signed session stay server-side, where a second factor belongs.


Last Updated
July 27, 2026
Category

Related articles

How to embed an Instagram feed on Webflow and stop double-posting Instagram content
How to embed an Instagram feed on Webflow and stop double-posting Instagram content

How to embed an Instagram feed on Webflow and stop double-posting Instagram content

How to embed an Instagram feed on Webflow and stop double-posting Instagram content

Development
By
Colin Lateano
,
,
Read article
How to use the Webflow CMS API to read, write, and publish content programmatically
How to use the Webflow CMS API to read, write, and publish content programmatically

How to use the Webflow CMS API to read, write, and publish content programmatically

How to use the Webflow CMS API to read, write, and publish content programmatically

Development
By
Colin Lateano
,
,
Read article
How to use the Webflow custom code API to push scripts to specific pages
How to use the Webflow custom code API to push scripts to specific pages

How to use the Webflow custom code API to push scripts to specific pages

How to use the Webflow custom code API to push scripts to specific pages

Development
By
Colin Lateano
,
,
Read article
How to wire Auth0 to a Webflow Cloud App for server-side session validation
How to wire Auth0 to a Webflow Cloud App for server-side session validation

How to wire Auth0 to a Webflow Cloud App for server-side session validation

How to wire Auth0 to a Webflow Cloud App for server-side session validation

Development
By
Colin Lateano
,
,
Read article

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.