Sending a one-time password is the easy half of phone verification. The half that decides whether your login is actually secure is what happens on your server when the user types the code back in.
This guide covers the rules for server-side OTP verification, with working Node.js and PHP examples for the EazyOTP API, where the platform generates and delivers the code and returns it to your backend.

How OTP verification works with EazyOTP
- Your server sends one GET request with the user’s
numberto the channel endpoint, for examplewhatsappeazyotp. - EazyOTP generates the code, delivers it and returns
status,track_idandotpin the response. - Your server stores a hash of the OTP with an expiry and an attempt counter.
- The user types the code; your server compares it, then deletes the record.
EazyOTP has no separate verify endpoint, so steps 3 and 4 are yours. That is good news: you control the expiry, attempt limits and logging. The rules below show how to get them right.
10 rules for verifying OTPs securely
1. Keep the OTP and the API key on the server
Call the EazyOTP API only from your backend; your API key is part of the request URL. Never return the otp field to the browser or app, not even “temporarily”.
2. Store a hash, not the code
Save an HMAC-SHA256 of the code using a secret “pepper” kept in an environment variable, not in the database. A plain hash isn’t enough for a 6-digit code: there are only a million possibilities, so anyone with the database could try them all.
3. Expire codes quickly
Give each code a short lifetime, around five minutes for logins, and reject it afterwards even if it is correct.
4. Allow each code to be used once
Delete the record as soon as verification succeeds, so a code can’t be replayed.
5. Limit attempts
Allow a small number of tries, for example five, then invalidate the code and require a new one. Without a limit, a 6-digit code can be guessed.
6. Throttle sends and resends
Add a cooldown between resends (30 to 60 seconds is common) and a daily cap per phone number, IP address and account. This protects users from message floods and protects you from paying for abuse.
7. Compare in constant time
Use crypto.timingSafeEqual in Node.js or hash_equals in PHP rather than ==, so response timing reveals nothing about the code.
8. Tie the code to its purpose
Store codes per phone and per action, such as login or payment, so a login code can’t approve a payment.
9. Log the track_id, never the OTP
Keep the track_id from each response so you can trace delivery complaints with webhook status (Sent, Delivered, Read). Never write OTPs to logs or analytics.
10. Block fraudulent OTP requests
Bots sometimes request OTPs in bulk to run up messaging bills, especially to international numbers. Use CAPTCHA on public forms, rate limits, and an allow-list of countries you serve. Return the same generic message whether or not a number exists.
Example: Node.js (Express)
// Node.js (Express) – example only. Use Redis or your database in production.
const crypto = require('crypto');
const PEPPER = process.env.OTP_PEPPER; // long random secret, kept outside the database
const store = new Map(); // key: phone + purpose
const hash = (otp) => crypto.createHmac('sha256', PEPPER).update(otp).digest('hex');
app.post('/otp/send', async (req, res) => {
const { phone } = req.body; // validate format and rate-limit first
const url = `https://app.pingtochat.com/api/v1.0/${process.env.EAZYOTP_KEY}/whatsappeazyotp?number=${encodeURIComponent(phone)}`;
const r = await fetch(url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.EAZYOTP_TOKEN}` } });
const data = await r.json();
if (data.status !== 'success') return res.status(502).json({ ok: false });
store.set(`${phone}:login`, { h: hash(data.otp), exp: Date.now() + 5 * 60e3, tries: 0, track: data.track_id });
res.json({ ok: true }); // never return data.otp to the client
});
app.post('/otp/verify', (req, res) => {
const key = `${req.body.phone}:login`;
const rec = store.get(key);
if (!rec || Date.now() > rec.exp || ++rec.tries > 5) { store.delete(key); return res.status(400).json({ ok: false }); }
const a = Buffer.from(hash(String(req.body.code))), b = Buffer.from(rec.h);
if (!crypto.timingSafeEqual(a, b)) return res.status(400).json({ ok: false });
store.delete(key); // one-time use
res.json({ ok: true });
});
Example: PHP
<?php
// PHP – verification only (sending is shown on /ready-to-use-otp-api/)
function otp_hash(string $otp): string {
return hash_hmac('sha256', $otp, getenv('OTP_PEPPER'));
}
function otp_verify(string $phone, string $typed): bool {
$key = "otp:$phone:login";
$rec = $_SESSION[$key] ?? null;
if (!$rec || time() > $rec['exp'] || ++$_SESSION[$key]['tries'] > 5) {
unset($_SESSION[$key]);
return false;
}
if (!hash_equals($rec['hash'], otp_hash($typed))) return false; // constant-time
unset($_SESSION[$key]); // one-time use
return true;
}
For the full request and response format, see the EazyOTP API documentation and our ready-to-use OTP API page, which includes a complete PHP send-and-verify example.
Checklist before you go live
- API calls are made only from the backend
- The OTP is never sent to the client
- Codes are stored as an HMAC with a secret pepper
- Codes expire after a few minutes and work once
- Attempts are limited, resends have a cooldown and a daily cap
- Comparison is constant-time
- Codes are scoped to phone and purpose
- Logs contain track_id but never the OTP
- CAPTCHA and country allow-list are on public forms
- An SMS or WhatsApp fallback is in place: see multi-channel OTP fallback
Frequently asked questions
Should OTP verification happen on the client or the server?
Always on the server. If the code or the comparison is in the browser or app, anyone can read it and skip the check.
Why does EazyOTP return the OTP in the API response?
So your backend can verify the user without a separate verify call. EazyOTP generates and delivers the code, and returns it with a track_id. Your server stores a hash of it and compares what the user types.
How long should an OTP be valid?
A few minutes is typical. Five minutes is a common choice for login codes. Shorter windows reduce the time an intercepted code is useful.
Is hashing a 6-digit OTP enough?
Not on its own, because there are only a million possible codes. Use an HMAC with a secret key kept outside the database, plus a short expiry and an attempt limit. Together these make guessing impractical.
How do I stop bots from triggering OTPs?
Rate-limit requests by phone number, IP address and account, add a cooldown between resends, use CAPTCHA on public forms, and only allow countries you actually serve.
Build your OTP flow in the sandbox
Create an account, send test OTPs to your own phone and wire up verification before you go live.
Prefer chat? Message EazyOTP on WhatsApp