OTP libraries for Node.js, Python and PHP
Send an SMS verification code and check it in a few lines, without building the code generation, expiry, attempt limit and resend wait yourself.
Sending a code is one API call (full explanation here). The work is everything around it: generate a random code, store it safely, check it is still valid, block guessing, and not send ten SMS to someone who clicks "resend" ten times. The Meser10 libraries do all of that, and are written to behave the same way in all three languages.
What the libraries do
- ✓Generate a random code (6 digits by default) and send it by SMS through your Meser10 account
- ✓Store the code only as a keyed hash (HMAC), never as text
- ✓A code works once, expires after 5 minutes by default, and locks after 5 wrong attempts
- ✓A new code for the same number only after a wait (60 seconds by default)
- ✓Limits are counted with an atomic operation in a store shared by all your servers, so parallel requests cannot get around them
- ✓No external dependencies (PHP needs only the json and curl extensions), with automated tests on Node 18, 20 and 22, Python 3.8 and 3.12, and PHP 7.4 and 8.3
Install
Node.js, from npm:
npm install @meser10/otpPython, straight from GitHub:
pip install "git+https://github.com/meser10/meser10-otp#subdirectory=python"PHP, with Composer:
composer config repositories.meser10-otp vcs https://github.com/meser10/meser10-otp
composer require meser10/otp:^1.0What you need from Meser10: SMS credit, an API key and an approved sender, up to 11 Latin letters or digits, or a mobile number verified on the account. You also need a random secret of at least 16 characters, the same on all servers, which signs the stored codes.
Node.js
const { Meser10Otp, Meser10Error } = require('@meser10/otp');
const otp = new Meser10Otp({
apiKey: process.env.MESER10_API_KEY,
sender: 'MyShop',
secret: process.env.OTP_SECRET,
message: 'Your MyShop code is {code}',
});
async function main(userInput) {
await otp.sendCode('054-123-4567');
try {
await otp.verifyCode('054-123-4567', userInput); // true
} catch (e) {
if (e instanceof Meser10Error) console.log(e.code); // wrong_code, locked...
}
}In Next.js, one route that handles both sending and checking:
// app/api/otp/route.js
import { Meser10Otp, Meser10Error } from '@meser10/otp';
import { redisStore } from '@/lib/otp-store';
const otp = new Meser10Otp({ apiKey: process.env.MESER10_API_KEY, sender: 'MyShop', secret: process.env.OTP_SECRET, store: redisStore });
export async function POST(req) {
const { phone, code } = await req.json();
try {
if (code) return Response.json({ ok: await otp.verifyCode(phone, code) });
await otp.sendCode(phone);
return Response.json({ sent: true });
} catch (e) {
if (e instanceof Meser10Error) return Response.json({ error: e.code }, { status: 400 });
throw e;
}
}Python
import os
from meser10_otp import Meser10Otp, Meser10Error
otp = Meser10Otp(os.environ["MESER10_API_KEY"], sender="MyShop", secret=os.environ["OTP_SECRET"])
otp.send_code("054-123-4567")
try:
otp.verify_code("054-123-4567", user_input) # True
except Meser10Error as e:
print(e.code)PHP
use Meser10\Otp\Otp;
use Meser10\Otp\RedisStore;
use Meser10\Otp\Meser10Error;
$redis = new Redis();
$redis->connect('127.0.0.1');
$otp = new Otp([
'apiKey' => getenv('MESER10_API_KEY'),
'sender' => 'MyShop',
'secret' => getenv('OTP_SECRET'),
'store' => new RedisStore($redis),
]);
$otp->sendCode('054-123-4567');
try {
$otp->verifyCode('054-123-4567', $_POST['code'] ?? ''); // true
} catch (Meser10Error $e) {
echo $e->errorCode;
}A shared store
On a single long-running server you can use the in-memory default of Node and Python. In every other case, several servers, several workers or serverless, pass a store shared by all of them. The store needs four operations: get, set, delete and an atomic incr. The incr is what keeps the attempt limit and the resend wait intact when several requests arrive at the same moment. The README of each library has a ready Redis store.
Options
| Option | Default | What it sets |
|---|---|---|
codeLength | 6 | Code length, 4 to 8 digits |
ttlSeconds | 300 | How long the code is valid |
maxAttempts | 5 | Wrong attempts before the code locks |
resendSeconds | 60 | Wait before a new code for the same number |
message | Your verification code is {code} | The message text. {code} is replaced with the code |
In Python the option names are lowercase with underscores, for example code_length and ttl_seconds.
Errors
Every error comes with a fixed code, so you can show the visitor the right message:
| Code | What happened |
|---|---|
invalid_phone | The number is not an Israeli mobile. Nothing was sent |
too_soon | A new code was requested before the wait ended |
api_error | Meser10 refused the send, for example a sender that is not approved. The reason is in the error message |
network | Meser10 could not be reached |
no_code | No active code for this number: none was sent, it was already used, or it expired |
expired | The code expired (usually reported as no_code, because the code is already deleted) |
wrong_code | The code is wrong. The message says how many attempts are left |
locked | Too many wrong attempts. A new code is needed |
bad_response | An unexpected reply from the Meser10 server |
config | A wrong setting, for example a secret shorter than 16 characters, no store in PHP, or a value out of range |
Add a limit per IP address and per account on top of the library as well: every code is a paid SMS.
Open source
The code of all three libraries is on GitHub, under the MIT license, with the automated tests:
https://github.com/meser10/meser10-otpGet an API key
Open a free Meser10 account, top up SMS credit and get your API key and sender.
Sign up freeContact usFrequently asked questions
How much do the libraries cost?
The libraries are free and open source (MIT license). Each code sent is one SMS from the balance on your Meser10 account, at your SMS rate.
Why do I need a shared store?
The code, the attempt counter and the resend wait have to be visible to every server that handles requests. The default store in Node and Python keeps them in the memory of one process, which only suits a single long-running server. On serverless or several servers, pass a store on Redis or your database.
Why is there no session default in PHP?
A visitor can delete the cookie and reset the session, and the limits with it. So PHP requires a shared store, and a ready RedisStore is included.
Can I send to numbers outside Israel?
By default the libraries accept only Israeli mobile numbers, so a code is not sent to a stranger by mistake. You can pass your own function to parse the number.
Is there a WordPress plugin?
Yes. Meser10 OTP adds a code at sign-up, at WooCommerce checkout and at login.