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
sendVerificationCodewhen verification starts andverifyCodewhen 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
sendVerificationCodebuilds a six-digit code withMath.floor(100000 + Math.random() * 900000), which can never produce a number with a leading zero or fewer than six digits.- It stores the code in the script cache under
2fa:<phone>, with aCODE_TTL_SECONDSlifetime. The cache evicts the entry automatically when that time is up — no separate cleanup job. sendSmstexts the code to the client through Twilio.- When the client submits a code,
verifyCodereads the cache for the same phone number. A miss — because the code expired or none was ever sent — returnsfalseimmediately. - Otherwise it compares the submitted code to the cached one and returns
whether they match. Your workflow only proceeds when this is
true. sendSmsdoes 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:
| Call | Result | Why |
|---|---|---|
verifyCode('+441632960111', '482915') | true | Matches the cached code |
verifyCode('+441632960111', '000000') | false | Wrong code |
verifyCode('+441632960111', '482915') after 6 min | false | Cache 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:
- Build a two-stage web app form. Stage one collects the phone number; on
submit, your
doPost(orgoogle.script.runhandler) callssendVerificationCode. - Stage two collects the six-digit code; on submit, the handler calls
verifyCodeand only commits the requested change when it returnstrue. - 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
CacheServiceis 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
sendVerificationCodefor 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
Bridge Sheets to Zapier or Make
Trigger external automations from Northwind Sheets via webhooks — no Apps Script logic needed downstream.
Updated Nov 8, 2025
Send rich notifications to Discord
Push Northwind deploy alerts and KPI updates to a Discord channel — embeds, not plain text.
Updated Oct 15, 2025
Build a payment-webhook receiver
Catch Stripe payment events into a Northwind sheet — paid invoices flip status instantly.
Updated Oct 11, 2025
Build a WhatsApp notification sender
Push Northwind updates via the WhatsApp Business API — for client billing milestones.
Updated Jul 23, 2025
Send SMS notifications with Twilio
Text Northwind alerts straight from your scripts — for production outages or VIP events.
Updated Jul 19, 2025