A one-time password (OTP) sent by SMS proves that a user owns the number they typed. It is the most common way to verify a sign-up, a sign-in or a payment. This guide shows how to do it properly in Node.js, from generating the code to checking it.
How it works
- The user enters their number.
- Your server generates a 6-digit code and keeps a hash of it (never the code itself) with an expiry time.
- The code goes out by SMS.
- The user types the code; your server compares the hashes and checks the expiry and the number of attempts.
1. Generate and store the code
Use crypto.randomInt, never Math.random, which is not built for security.
import { randomInt, createHash } from "node:crypto";
const hash = (code) => createHash("sha256").update(code).digest("hex");
export function createOtp(phone) {
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
const record = {
phone,
codeHash: hash(code),
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
attempts: 0,
};
return { code, record }; // save record in your database or in Redis
}
2. Send the code by SMS
Node.js 18 and later ship with fetch, so no dependency is needed.
export async function sendOtp(phone, code, requestId) {
const response = await fetch("https://api.sendimo.tn/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDIMO_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `otp-${requestId}`,
},
body: JSON.stringify({
to: phone, // E.164 format: +21620123456
from: "SENDIMO",
text: `Your code is ${code}. It expires in 5 minutes.`,
}),
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(`${error.code}: ${error.message}`);
}
return response.json(); // { id, status: "queued", segments: 1, cost, ... }
}
The Idempotency-Key stops two codes from going out if your server retries the same request after a network failure.
3. Check the code
const MAX_ATTEMPTS = 5;
export function verifyOtp(record, input) {
if (Date.now() > record.expiresAt) return { ok: false, reason: "expired" };
if (record.attempts >= MAX_ATTEMPTS) return { ok: false, reason: "locked" };
record.attempts += 1; // save it
if (hash(input.trim()) !== record.codeHash) return { ok: false, reason: "wrong_code" };
return { ok: true }; // delete the record: a code works only once
}
4. Protect against abuse
An open OTP form can be used to send SMS at your expense. A few simple rules:
- Limit sends: for example 1 code per minute and 5 per hour for the same number, plus a limit per IP address.
- Limit attempts: 5 tries per code, then a new code is required.
- Expire quickly: 5 minutes is enough in most cases.
- Don't reveal whether the number exists in your database: return the same message every time.
Writing a good OTP message
- Stay in GSM-7 and under 160 characters to pay for one segment only: no emoji, and in French avoid ê, â, ô and ç, which force Unicode.
- Put the code first in the message: it shows in the notification without opening the SMS.
- State how long it is valid and, if needed, "never share this code".
Test before going live
With an sk_test_ key, +21600000001 always ends delivered and +21600000002 always ends failed. Nothing is sent or charged, so you can test a failed delivery as well as a successful one.
How much does an OTP cost?
A one-segment code costs from 0.100 DT to 0.150 DT depending on your plan, so 120 DT for 1,000 verifications with the Pro pack. The details are in our article on what an SMS costs in Tunisia.
Read next
Subscribe to get daily insights and company news straight to your inbox.