Short answer: for an edtech signup, keep the verification-link template in your application, send SMS first, switch to a short-lived email code only after a confirmed delivery timeout, and let the browser poll one status record instead of retrying blindly. This keeps copy ownership clear while preventing a slow carrier from creating duplicate accounts.
A student may have one chance to finish registration between classes. The useful unit is one challenge: create it once, record every channel attempt, and expose only a redacted status to the browser. I care about template ownership because a school name, locale, or support link should be changeable in a reviewed commit, not hidden in a provider console.
1. What should a signup flow do when SMS delivery fails?
Treat “failed” as a state you can prove, not an exception that triggers an immediate resend. A send request being accepted is not delivery. Store a challenge ID, a salted code hash, an expiry (five minutes is a reasonable starting point to tune), and an idempotency key. The SMS worker can move through queued, sent, and delivered; a timeout moves the same challenge to email_pending. Never reveal whether a phone number or email is registered.
The fallback email contains a verification link owned by the app. The link consumes the same challenge, so a late SMS cannot create a second identity. Rate-limit attempts per account, IP, phone, and email, invalidate the code after a successful use, and return the same generic response for unknown accounts. OWASP's MFA guidance is a useful baseline for these controls.
One challenge. One proof.
2. How can a TypeScript state machine keep the fallback testable?
The example keeps transport adapters generic. sendSms and sendEmail can wrap any service, while the state machine and template remain yours. The function returns an opaque challenge ID; it never returns the secret code. In a real deployment, replace the in-memory map and timer with durable storage and a job queue so a process restart cannot erase pending signups.
type Channel = "sms" | "email";
type State = "queued" | "sent" | "delivered" | "email_pending" | "verified" | "expired";
interface Challenge {
id: string;
userId: string;
state: State;
channel: Channel;
expiresAt: number;
codeHash: string;
}
const challenges = new Map<string, Challenge>();
export async function beginSignup(userId: string, phone: string, email: string) {
const id = crypto.randomUUID();
const code = String(Math.floor(100000 + Math.random() * 900000));
const challenge: Challenge = {
id,
userId,
state: "queued",
channel: "sms",
expiresAt: Date.now() + 5 * 60_000,
codeHash: await hash(code)
};
challenges.set(id, challenge);
await sendSms(phone, `Verify your school account with code ${code}`);
challenge.state = "sent";
setTimeout(async () => {
if (challenge.state === "sent" && Date.now() < challenge.expiresAt) {
challenge.channel = "email";
challenge.state = "email_pending";
await sendEmail(email, `Verify your account with code ${code}`);
}
}, 45_000);
return { challengeId: id };
}
export function verificationStatus(id: string) {
const item = challenges.get(id);
if (!item || item.expiresAt <= Date.now()) return { state: "expired" as const };
return { state: item.state, channel: item.channel };
}
The verification handler should compare a one-way hash, check expiry, and atomically mark the record verified; a second request gets a harmless “already used” result. Use a cryptographically secure random-number generator in production rather than Math.random(), and keep that replacement inside the challenge service so the rest of the flow stays easy to test.
3. How do SMS fallback, email codes, and delivery polling fit together?
The browser polls the challenge status every two seconds, adds exponential backoff after the first 30 seconds, and stops at verified or expired. Polling reports progress, not proof: only the link or code handler can establish possession. Return Cache-Control: no-store, omit destination addresses, and cap polling at a minute so an abandoned tab does not become a background load generator.
The email template should explain why the message arrived, identify the school, show the expiry, and include a support path. DKIM signing helps receiving systems authenticate that mail, but it does not guarantee inbox placement; SPF, DMARC policy, bounce processing, and a visible plain-text alternative still matter.
Here is the awkward race. A carrier may report delivery after the user has already requested email. Keep one active challenge and accept the first valid proof; do not mint a second code for the fallback. I am not sure any universal timeout exists. Measure by country, carrier, and hour, then tune the 45-second handoff instead of treating it as a law.
Picture a student on a campus Wi-Fi network: the SMS request is accepted, the tab polls twice, and the student clicks “send email” after seeing no progress. The worker must still point both attempts at the original challenge, preserve the original expiry, and make the status response say only email_pending. If the SMS arrives a few seconds later, its code remains valid until the first proof is consumed; if the email link is used first, the SMS handler must return the same already-used outcome. Logging both provider event IDs against that one challenge lets support explain the timeline without exposing the phone number, email address, or code. This is a longer path than “catch and retry,” but it is the difference between a recoverable signup and two accounts competing for one student.
Not twice.
4. Which template ownership and launch checks fit an edtech team?
Application-owned templates make localization, accessibility review, and school branding a code-reviewed change. They also make you responsible for translations, rendering tests, signing-key rotation, bounce handling, and regional sender rules. Provider-hosted templates reduce that operational work but can constrain variables and approval workflows.
| Decision | App-owned template | Hosted template |
|---|---|---|
| Copy and locale changes | Pull request and release | Provider console or approval |
| Delivery evidence | Your event schema | Provider event schema |
| Operational load | Higher | Lower |
| Lock-in risk | Lower at the message layer | Higher if variables are proprietary |
Choose an app-owned template when product and compliance teams need an audit trail. Stick with a hosted template when a small team cannot operate DKIM, bounce handling, and regional sender rules yet. The catch is maintenance: app ownership is not suitable when no one can review security-sensitive copy or rotate signing keys. In that case, narrow the surface area, document the boundary, and revisit ownership after launch.
Run tests with a fake clock: SMS delivered, SMS delayed, SMS rejected, email delayed, duplicate clicks, expired links, and two browser tabs polling the same challenge. Assert that logs contain IDs and states but never codes or full addresses. Send synthetic messages to a monitored inbox and verify DKIM alignment, bounce handling, and unsubscribe behavior for non-transactional mail.
Watch p50 and p95 time to verification by channel, fallback rate, code-attempt failures, and poll volume. Alert on a sudden change in one region, then inspect carrier and mail events before changing retry policy. Keep retention short for challenge data, and make deletion follow the account's privacy policy.
The operational checklist is prose: one durable challenge, one expiry, one atomic proof, redacted status, bounded polling, and a reviewed template. If any of those is missing, the fallback is guesswork rather than recovery.
Top comments (0)