How to Build a Secure SMS OTP Verification Endpoint in Node.js
Sending a six-digit code by SMS looks trivial, but most OTP bugs are not in the SMS part. They're in the endpoints around it: codes that never expire, codes stored in plain text, unlimited guesses, and a "resend" button
Sending a six-digit code by SMS looks trivial, but most OTP bugs are not in the SMS part. They're in the endpoints around it: codes that never expire, codes stored in plain text, unlimited guesses, and a "resend" button that lets anyone burn your SMS budget. This guide builds a small, secure OTP verification endpoint in Node.js with Express. Then it covers what to change before production and how to test it against a real phone network.
What a secure OTP flow needs
Before writing code, here's the checklist the endpoint has to satisfy:
-
Unpredictable codes: generated with a cryptographic random source, never
Math.random(). - Short expiry: about five minutes, then the code is useless.
- Single use: a code that worked once is deleted immediately.
- Limited attempts: five wrong guesses and the code is gone. Six digits give a million combinations, but unlimited attempts make brute force practical.
- No plain-text storage: store a keyed hash of the code, not the code itself.
-
Constant-time comparison: compare hashes with
crypto.timingSafeEqual. - Resend cooldown: at least a minute between sends to the same number.
-
Normalised phone numbers: one canonical format (E.164) so
+44 7700 900123and07700900123can't create two separate counters.
The code
This is a compact Express app. It keeps state in a Map to stay readable; the production section below replaces that with Redis.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json());
const OTP_TTL_MS = 5 * 60 * 1000; // codes live for 5 minutes
const MAX_ATTEMPTS = 5; // wrong guesses before the code is burned
const RESEND_COOLDOWN_MS = 60 * 1000; // one SMS per number per minute
const SECRET = process.env.OTP_SECRET; // long random string, never committed
// phone -> { hash, expiresAt, attempts, lastSentAt }
const store = new Map();
function normalizeE164(input) {
const s = String(input || '').replace(/[\s().-]/g, '');
return /^\+[1-9]\d{7,14}$/.test(s) ? s : null;
}
function hashCode(phone, code) {
return crypto.createHmac('sha256', SECRET).update(`${phone}:${code}`).digest();
}
async function sendSms(to, body) {
// Call your SMS provider here (Twilio, MessageBird, Vonage, ...).
console.log(`SMS to ${to}: ${body}`);
}
app.post('/otp/send', async (req, res) => {
const phone = normalizeE164(req.body.phone);
if (!phone) return res.status(400).json({ error: 'invalid_phone' });
const existing = store.get(phone);
if (existing && Date.now() - existing.lastSentAt < RESEND_COOLDOWN_MS) {
return res.status(429).json({ error: 'too_soon' });
}
const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
store.set(phone, {
hash: hashCode(phone, code),
expiresAt: Date.now() + OTP_TTL_MS,
attempts: 0,
lastSentAt: Date.now(),
});
await sendSms(phone, `Your Example code is ${code}. It expires in 5 minutes.`);
res.json({ ok: true });
});
app.post('/otp/verify', (req, res) => {
const phone = normalizeE164(req.body.phone);
const entry = phone && store.get(phone);
if (!entry || Date.now() > entry.expiresAt) {
return res.status(400).json({ error: 'expired_or_missing' });
}
if (entry.attempts >= MAX_ATTEMPTS) {
store.delete(phone);
return res.status(429).json({ error: 'too_many_attempts' });
}
entry.attempts += 1;
const given = String(req.body.code || '');
const ok = /^\d{6}$/.test(given) &&
crypto.timingSafeEqual(hashCode(phone, given), entry.hash);
if (!ok) return res.status(400).json({ error: 'wrong_code' });
store.delete(phone); // single use
res.json({ ok: true });
});
app.listen(3000);
Why each piece is there
crypto.randomInt draws from the operating system's secure random generator. Math.random() is predictable enough that it should never generate secrets.
HMAC instead of storing the code. If your database or cache leaks, plain six-digit codes would be readable. An HMAC keyed with a server secret is useless without that secret, and it's cheap to compute.
timingSafeEqual compares two buffers in constant time, so response timing can't reveal how many bytes matched. Both HMAC digests are 32 bytes, which the function requires.
Counting attempts before checking means a burst of parallel wrong guesses still runs into the limit. Deleting the entry after too many attempts forces the user to request a new code, and that request is itself rate-limited by the cooldown.
Normalising to E.164 first gives every rate limit and counter a single key per real number. The regex above is a minimal check. In a real app, parse numbers with a library such as libphonenumber-js so national formats are converted correctly.
Before production
-
Move state to Redis. Use
SET key value PX 300000so expiry is automatic, andINCRfor attempts. AMapdisappears on restart and doesn't work across several instances. - Rate-limit per IP and per session too, not only per number. Attackers rotate numbers.
- Restrict destination countries to the ones you actually serve. Paying for SMS to premium-rate ranges you never asked for is the core of SMS pumping fraud.
- Watch the ratio of codes sent to codes verified. A sudden drop means someone is triggering sends without completing sign-ups.
- Keep the message short and clear: your app's name, the code, the expiry, and no links. Messages under 160 GSM-7 characters arrive as a single SMS.
- Offer a fallback such as an authenticator app or email for users whose carrier filters your messages.
Testing it
Unit tests should stub sendSms and capture the code from the call, so you can assert on expiry, attempt limits and the cooldown without sending anything. Use fake timers to move past the five-minute window.
For an end-to-end check on a real network, point your staging environment at your SMS provider and send a code to a real number in each country you launch in. Free public numbers on Temp SMS are handy for this: the message shows up on a web page, so you can see exactly how it arrives, including the sender ID, encoding and delay. Messages on those numbers are public, so use them only for test accounts.
FAQ
Should I use 4, 6 or 8 digits?
Six digits with a five-attempt limit is the common balance between security and usability.
Can I store the OTP in a JWT instead of a database?
You can sign the hash and expiry into a token, but you still need server-side state to enforce single use and attempt limits.
Is SMS OTP secure enough for login?
It's better than a password alone, but SIM swapping and interception exist. Offer an authenticator app or passkeys for higher-risk accounts.
How long should a code stay valid?
Five to ten minutes. Long enough for slow deliveries, short enough to limit guessing.
More in this series on SMS verification:
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.