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.

Server-side OTP verification flow: send, store a hash, compare in constant time, delete

How OTP verification works with EazyOTP

  1. Your server sends one GET request with the user’s number to the channel endpoint, for example whatsappeazyotp.
  2. EazyOTP generates the code, delivers it and returns status, track_id and otp in the response.
  3. Your server stores a hash of the OTP with an expiry and an attempt counter.
  4. 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

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