appscript.dev
Automation Advanced

Build a two-factor SMS verification step

Add phone verification to a Northwind workflow — code via Twilio, validated by web app.

Published Dec 18, 2025

Northwind lets clients request account changes through a form, but a form submission proves nothing about who actually sent it. For sensitive actions — changing a payout account, say — Northwind wants a second factor: a six-digit code texted to the phone number on file, which the client has to type back before the change goes through.

This script is that verification step. sendVerificationCode generates a code, stashes it in the script cache with a five-minute expiry, and texts it through Twilio. verifyCode checks a code the client submits against what is in the cache. Because the cache entry expires on its own, an unused code quietly stops working — there is no stale state to clean up.

What you’ll need

  • A Twilio account with an SMS-capable phone number.
  • Three values saved in Script Properties — see Store API keys and secrets securely:
    • TWILIO_SID — your Account SID.
    • TWILIO_TOKEN — your Auth Token.
    • TWILIO_FROM — your Twilio number, in E.164 format (e.g. +441632960000).
  • A workflow that calls sendVerificationCode when verification starts and verifyCode when the client submits the code — typically a web app form.

The script

// How long a code stays valid, in seconds. Five minutes is long enough
// to receive an SMS and type it, short enough to limit a stolen code.
const CODE_TTL_SECONDS = 300;

// Prefix for cache keys so 2FA entries never collide with other cache use.
const CACHE_PREFIX = '2fa:';

/**
 * Generates a six-digit code, caches it against the phone number, and
 * texts it to that number.
 *
 * @param {string} phone  Destination number in E.164 format, e.g. +44...
 */
function sendVerificationCode(phone) {
  // Six digits, always padded — 100000 to 999999, never a short code.
  const code = String(Math.floor(100000 + Math.random() * 900000));

  // Store the code keyed by phone number. The entry expires by itself
  // after CODE_TTL_SECONDS, so an unused code simply stops working.
  CacheService.getScriptCache().put(CACHE_PREFIX + phone, code, CODE_TTL_SECONDS);

  // Send the SMS.
  sendSms(phone, 'Your Northwind code: ' + code);
}

/**
 * Checks a code the user submitted against the cached one.
 *
 * @param {string} phone  The number the code was sent to.
 * @param {string} code   The code the user typed back.
 * @return {boolean} True only if the code matches and has not expired.
 */
function verifyCode(phone, code) {
  const expected = CacheService.getScriptCache().get(CACHE_PREFIX + phone);

  // A cache miss (expired or never sent) means no valid code exists.
  if (!expected) return false;

  return expected === code;
}

/**
 * Sends a single SMS via the Twilio REST API.
 *
 * @param {string} to    Destination number in E.164 format.
 * @param {string} body  The message text.
 */
function sendSms(to, body) {
  const p = PropertiesService.getScriptProperties();
  const sid = p.getProperty('TWILIO_SID');

  // Twilio authenticates with HTTP Basic: "SID:AuthToken", base64-encoded.
  const auth = Utilities.base64Encode(sid + ':' + p.getProperty('TWILIO_TOKEN'));

  const res = UrlFetchApp.fetch(
    'https://api.twilio.com/2010-04-01/Accounts/' + sid + '/Messages.json',
    {
      method: 'post',
      headers: { Authorization: 'Basic ' + auth },
      // Twilio's Messages endpoint expects form fields, not JSON.
      payload: { From: p.getProperty('TWILIO_FROM'), To: to, Body: body },
      muteHttpExceptions: true,
    }
  );

  // Surface delivery failures (bad number, unverified trial number, etc.).
  const code = res.getResponseCode();
  if (code < 200 || code >= 300) {
    throw new Error('Twilio rejected the SMS (' + code + '): ' + res.getContentText());
  }
}

How it works

  1. sendVerificationCode builds a six-digit code with Math.floor(100000 + Math.random() * 900000), which can never produce a number with a leading zero or fewer than six digits.
  2. It stores the code in the script cache under 2fa:<phone>, with a CODE_TTL_SECONDS lifetime. The cache evicts the entry automatically when that time is up — no separate cleanup job.
  3. sendSms texts the code to the client through Twilio.
  4. When the client submits a code, verifyCode reads the cache for the same phone number. A miss — because the code expired or none was ever sent — returns false immediately.
  5. Otherwise it compares the submitted code to the cached one and returns whether they match. Your workflow only proceeds when this is true.
  6. sendSms does the Twilio call: it builds an HTTP Basic auth header from the SID and token, POSTs the message as form fields, and throws on any non-2xx response so a failed text never passes silently.

Example run

A client requests a payout-account change. Your form’s back end calls:

sendVerificationCode('+441632960111');

The client’s phone receives:

Your Northwind code: 482915

They type 482915 back into the form, and the back end checks it:

CallResultWhy
verifyCode('+441632960111', '482915')trueMatches the cached code
verifyCode('+441632960111', '000000')falseWrong code
verifyCode('+441632960111', '482915') after 6 minfalseCache entry has expired

Only the first call lets the workflow continue.

Run it

This is not a standalone, runnable function — it is a step you call from a larger workflow, almost always a web app:

  1. Build a two-stage web app form. Stage one collects the phone number; on submit, your doPost (or google.script.run handler) calls sendVerificationCode.
  2. Stage two collects the six-digit code; on submit, the handler calls verifyCode and only commits the requested change when it returns true.
  3. Test end to end with a real phone before wiring it to anything sensitive — the script cache and Twilio both behave differently in the editor than in a deployed web app.

Watch out for

  • CacheService is best-effort storage. Under memory pressure Apps Script can evict a cache entry before its TTL — rare, but it means a code occasionally fails verification even within the window. For a hard guarantee, store the code in Script Properties with an explicit expiry timestamp instead.
  • The code is keyed by phone number, so a fresh sendVerificationCode for the same number overwrites the previous code. That is usually what you want (a “resend” link), but only the most recent code is ever valid.
  • Add a resend and attempt limit. Nothing here stops a caller from triggering unlimited texts or guessing the code repeatedly — count attempts per number (a second cache key works) and lock out after a few failures.
  • Twilio trial accounts can only text verified numbers and prepend a trial banner to every message. Upgrade before going live.
  • Codes are SMS, which is not the strongest second factor — SIM-swap attacks exist. It is a real improvement over a form alone, but treat it as such.
  • Use E.164 numbers (+44...) everywhere. A locally formatted number will be rejected by Twilio or, worse, sent to the wrong country.

Related