Two-factor authentication is one of those features that sounds simple until you're in the middle of building it. OTP generation, expiry windows, rate limiting, delivery channel fallback — it adds up fast. In this guide we'll build a complete 2FA flow using Wasika in under 50 lines of application code.
The flow
1. User submits their phone number. 2. We generate a 6-digit OTP, store it with a 10-minute TTL, and send it via SMS. If SMS fails (e.g. the number is a VoIP line), we fall back to email automatically. 3. User submits the OTP. We verify it's valid and unexpired, then mark the session authenticated.
Generating and sending the OTP
import { Wasika } from "@wasika/sdk";
import { createClient } from "redis";
import crypto from "crypto";
const wasika = new Wasika({ apiKey: process.env.WSK_KEY });
const redis = createClient();
export async function sendOtp(phoneNumber: string, email: string) {
const otp = crypto.randomInt(100_000, 999_999).toString();
const key = \`otp:${phoneNumber}\`;
await redis.set(key, otp, { EX: 600 }); // 10-minute TTL
await wasika.messages.send({
channel: "sms",
to: phoneNumber,
body: \`Your Wasika verification code is ${otp}. Expires in 10 minutes.\`,
fallback: [{ channel: "email", to: email,
subject: "Your verification code",
body: \`Your code is ${otp}\` }],
});
}
The fallback field does the heavy lifting. If the SMS fails at the carrier level, Wasika automatically sends the email instead — no retry logic in your application code, no second API call.
Verifying the OTP
export async function verifyOtp(phoneNumber: string, submitted: string) {
const key = \`otp:${phoneNumber}\`;
const stored = await redis.get(key);
if (!stored || stored !== submitted) {
throw new Error("Invalid or expired code");
}
await redis.del(key); // single-use
return true;
}
Rate limiting
Don't forget to rate-limit the send endpoint — at minimum, one OTP per phone number per minute, and a hard cap of 5 per hour. Redis makes this straightforward with a counter key and a sliding window TTL. The verification endpoint should also lock out after 5 failed attempts to prevent brute-force.
That's the core. In production you'd add the rate limiting middleware, hook the webhook to mark delivery confirmed, and surface a 'resend' button on the frontend after a 60-second cooldown. Total application logic: well under 50 lines.