If you've ever signed up for a bank app, confirmed a purchase, or logged into an account from a new device, you've used a One-Time Password (OTP). It's one of the most common security mechanisms on the internet — and also one of the most misunderstood.
This guide breaks down what OTP actually means, the different ways it's delivered, how the underlying flow works, and how to add it to your own application correctly.
A One-Time Password (OTP) is a numeric or alphanumeric code that is valid for only a single login session or transaction, and typically expires after a short window of time (usually 60 seconds to 10 minutes).
Unlike a regular password, an OTP is:
Because it's generated fresh each time and expires quickly, an intercepted or leaked OTP has a very short window in which it's actually useful to an attacker — which is why OTP is a core building block of two-factor authentication (2FA).
At a high level, every OTP flow follows the same five steps, regardless of channel:
[ User requests login ]
│
▼
[ Server generates OTP + stores with TTL ]
│
▼
[ OTP delivered via SMS / Email / WhatsApp ]
│
▼
[ User submits code ]
│
▼
[ Server verifies code + expiry ] ──▶ Match: grant access, invalidate code
└▶ No match / expired: reject, allow retry
Not all OTPs are delivered the same way. Each channel has different trade-offs in reach, speed, and cost.
| Delivery Method | How It Works | Best For | Trade-offs |
|---|---|---|---|
| SMS OTP | Code sent as a text message to the user's phone | Universal reach, works on any phone, no app required | Depends on carrier delivery speed; can be delayed on poor networks |
| Email OTP | Code sent to the user's inbox | Users without reliable SMS reception, low-cost fallback | Slower (seconds to minutes), spam-filter risk |
| WhatsApp OTP | Code delivered via a WhatsApp template message | High engagement, rich formatting | Requires the user to have WhatsApp and have opted in |
| Voice OTP | An automated call reads the code aloud | Accessibility, users who can't read SMS | Higher cost per delivery, requires answering a call |
| App-based (TOTP) | An authenticator app (e.g., Google Authenticator) generates a code locally using a shared secret and the current time | No network dependency once set up, most phishing-resistant | Requires the user to install and set up an app first |
Most production systems don't rely on a single channel — they use SMS as the primary channel with email or WhatsApp as an automatic fallback if the first delivery attempt fails.
These two terms get confused often:
In short: all TOTP codes are OTPs, but not all OTPs are TOTP. An SMS code your server generates and sends is an OTP; a 6-digit code Google Authenticator shows you is a TOTP.
Here's what generating and sending an OTP actually looks like using the Sendexa API. First, the request to send the code:
curl -X POST https://api.sendexa.co/v1/sms/send \
-u "$SENDEXA_API_KEY:$SENDEXA_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"to": "+233501234567",
"from": "SENDEXA",
"message": "Your verification code is 849301. It expires in 5 minutes."
}'
And the equivalent using the Node.js SDK, with a minimal generate-and-verify implementation around it:
import { Sendexa } from "@sendexa/sdk";
import crypto from "crypto";
const sendexa = new Sendexa({
apiKey: process.env.SENDEXA_API_KEY,
apiSecret: process.env.SENDEXA_API_SECRET,
});
// In-memory store for demo purposes — use Redis or your database in production.
const otpStore = new Map();
function generateOTP() {
return crypto.randomInt(100000, 999999).toString();
}
export async function requestOTP(phoneNumber) {
const code = generateOTP();
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
otpStore.set(phoneNumber, { code, expiresAt, attempts: 0 });
await sendexa.sms.send({
to: phoneNumber,
from: "SENDEXA",
message: `Your verification code is ${code}. It expires in 5 minutes.`,
});
}
export function verifyOTP(phoneNumber, submittedCode) {
const record = otpStore.get(phoneNumber);
if (!record) return { success: false, reason: "No OTP requested" };
if (Date.now() > record.expiresAt) {
otpStore.delete(phoneNumber);
return { success: false, reason: "OTP expired" };
}
if (record.attempts >= 3) {
otpStore.delete(phoneNumber);
return { success: false, reason: "Too many attempts" };
}
record.attempts += 1;
if (record.code !== submittedCode) {
return { success: false, reason: "Invalid code" };
}
otpStore.delete(phoneNumber);
return { success: true };
}
A working OTP flow isn't automatically a secure one. A few rules matter more than the rest:
2–5 minutes is the sweet spot. Long-lived codes give attackers a bigger window and don't meaningfully help legitimate users.
Limit how many OTPs can be requested per phone number and per IP address in a given window. Without this, your SMS bill becomes an attack surface — a technique known as SMS pumping or toll fraud, where bots trigger OTPs to premium-rate numbers to generate carrier payouts at your expense.
Three to five tries per code, then invalidate it and force a new request. This blocks brute-forcing a 6-digit space.
Each new request should generate a fresh code, not resend or extend the previous one.
If SMS delivery fails or is delayed, automatically retry via email or WhatsApp rather than leaving the user stuck.
Use a cryptographically secure random number generator (like crypto.randomInt in Node.js) — never Math.random() — for code generation.
OTP is deceptively simple on the surface — generate a code, send it, check it — but the details around expiry, rate limiting, and fallback delivery are what separate a secure implementation from one that quietly leaks money to fraud or loses users to failed deliveries.
If you're building OTP verification into your product, Sendexa gives you a single API for SMS, email, and WhatsApp delivery, so your fallback logic is a few lines of code instead of three separate vendor integrations.
