If you’re building a Node.js app that requires users to verify their phone numbers during signup, login, or account recovery, you’ll need to send a one-time password (OTP) and confirm that the user entered the correct code.
A phone verification API can handle much of this work, including OTP generation, expiration, delivery, retries, and failed verification attempts, without requiring you to build and maintain the entire verification system yourself.
In this tutorial, we’ll walk through how to add phone number verification to a Node.js app using Express and SecondFactor’s API.
How to Add Phone Verification to a Node.js App
We’ll use Express.js to create a Node.js backend and SecondFactor’s API to send and verify one-time passwords.
You’ll need Node.js 18 or later and a SecondFactor account to get started.
Step 1: Set Up Your Node.js Project
First, create a new directory for your project and initialize it with npm.
mkdir phone-verification
cd phone-verification
npm init -y
Next, install Express, dotenv, and express-session. Express will handle our API endpoints, dotenv will load environment variables, and express-session will let us associate a verification request with a user session.
npm install express dotenv express-session
Step 2: Get Your SecondFactor API Credentials
Sign in to your SecondFactor dashboard and get your API key and Service SID. You can find the Service SID on the Settings page.
Create a .env file in your project directory and add the following variables:
SECONDFACTOR_API_KEY=your_api_key
SECONDFACTOR_SERVICE_SID=your_service_sid
SESSION_SECRET=your_random_session_secret
PORT=3000
Replace the placeholders with your actual credentials and a securely generated session secret. Make sure you also have a default OTP template configured in SecondFactor.
Add .env and node_modules/ to your .gitignore file so you don’t accidentally commit your API credentials.
Step 3: Create an API Endpoint to Send OTPs
Next, create an app.js file. We’ll add a /send-otp endpoint that accepts a phone number and requests a verification code from SecondFactor.
SecondFactor’s API accepts phone numbers in E.164 format, such as +14155552671. When the request succeeds, the API returns a verification SID that we’ll store in the user’s session and use later to check the OTP.
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const { randomUUID } = require('crypto');
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production'
}
}));
app.post('/send-otp', async (req, res) => {
const { phoneNumber } = req.body;
if (typeof phoneNumber !== 'string' ||
!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
return res.status(400).json({
error: 'Enter a valid phone number in E.164 format.'
});
}
try {
const response = await fetch(
`https://api.secondfactor.ai/v2/Services/${process.env.SECONDFACTOR_SERVICE_SID}/Verifications`,
{
method: 'POST',
headers: {
'X-API-Key': process.env.SECONDFACTOR_API_KEY,
'Idempotency-Key': randomUUID(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ To: phoneNumber })
}
);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({
error: 'Could not send verification code.'
});
}
req.session.pendingVerification = {
sid: data.sid,
phoneNumber: data.to
};
return res.json({
message: 'Verification code requested.'
});
} catch (error) {
return res.status(502).json({
error: 'Verification service unavailable.'
});
}
});
app.listen(process.env.PORT || 3000, () => {
console.log('Server running on port 3000');
});
The endpoint sends a POST request to SecondFactor with the phone number and your API key. SecondFactor generates the OTP and queues it for delivery through an eligible channel.
The response includes a unique verification SID. We store that SID on the server rather than ask the user to submit it, so the next endpoint can check the code against the verification started in the same session.
For this tutorial, Express uses its default in-memory session store. A production application should use a persistent session store, such as Redis, and enforce request limits before sending OTPs.
Step 4: Create an API Endpoint to Verify the OTP
Once the user receives the verification code, they need to enter it in your application. We’ll create a /verify-otp endpoint that accepts the code and checks it using SecondFactor’s API.
Add the following code to your app.js file, before the app.listen() statement.
app.post('/verify-otp', async (req, res) => {
const { code } = req.body;
const verification = req.session.pendingVerification;
if (!verification) {
return res.status(400).json({
error: 'No pending verification. Request a new code.'
});
}
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
return res.status(400).json({
error: 'Enter a valid six-digit code.'
});
}
try {
const response = await fetch(
`https://api.secondfactor.ai/v2/Services/${process.env.SECONDFACTOR_SERVICE_SID}/VerificationCheck`,
{
method: 'POST',
headers: {
'X-API-Key': process.env.SECONDFACTOR_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
VerificationSid: verification.sid,
Code: code
})
}
);
const data = await response.json();
if (response.status === 200 && data.status === 'VERIFIED') {
req.session.verifiedPhone = data.to;
delete req.session.pendingVerification;
return res.json({
message: 'Phone number verified successfully.',
verified: true
});
}
if (response.status === 422) {
return res.status(400).json({
error: 'Incorrect verification code. Please try again.',
attemptsRemaining: data.attempts_remaining
});
}
if (response.status === 409 && data.status === 'LOCKED') {
delete req.session.pendingVerification;
return res.status(429).json({
error: 'Too many attempts. Request a new code.'
});
}
if (response.status === 409 || response.status === 404) {
delete req.session.pendingVerification;
return res.status(410).json({
error: 'Verification is no longer available. Request a new code.'
});
}
return res.status(502).json({
error: 'Unable to verify code.'
});
} catch (error) {
return res.status(502).json({
error: 'Verification service unavailable.'
});
}
});
The endpoint retrieves the verification SID stored in the user’s session and submits it along with the entered code to SecondFactor.
If the code is correct, SecondFactor responds with HTTP 200 and status: "VERIFIED". Our application then records the verified phone number in the session and removes the pending verification.
An incorrect code returns HTTP 422 with status: "PENDING" and the number of attempts remaining. Only HTTP 200 means the code is correct, so our code treats every other response as a failed check.
SecondFactor allows five verification attempts per code. After the fifth unsuccessful attempt, the verification is locked, and the user needs to request a new OTP.
For an application with user accounts, you should also save the verified status against the user’s account in your database.
Step 5: Test the Phone Verification Flow
Now that both endpoints are ready, let’s test the complete verification process.
First, start your Node.js application from the project directory.
node app.js
Your Express server should now be running at http://localhost:3000.
Send a verification code
Open another terminal and send a POST request to /send-otp using curl. Replace the example phone number with a number you can access.
curl -X POST http://localhost:3000/send-otp \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"phoneNumber":"+14155552671"}'
If the request succeeds, you’ll receive the following response:
{
"message": "Verification code requested."
}
SecondFactor generates a six-digit OTP and queues it for delivery to the phone number. The successful API response confirms that the verification request was accepted, not necessarily that the code has been delivered.
Verify the received code
Once you receive the OTP, submit it to the /verify-otp endpoint. Replace 123456 with the actual code you received.
curl -X POST http://localhost:3000/verify-otp \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"code":"123456"}'
If the code is correct, the application returns:
{
"message": "Phone number verified successfully.",
"verified": true
}
The application now has a verified phone number associated with the user’s session. You can use this result to complete registration, allow account access, or update the user’s phone verification status in your database.
Notice that we’re using cookies.txt in both requests. This preserves the same Express session between sending and verifying the code.
Without the session cookie, the /verify-otp endpoint won’t be able to retrieve the pending verification SID.
You can also test an incorrect OTP before submitting the correct code to confirm that your application handles failed verification attempts properly.
How to Handle OTP Errors, Expiration, and Resends
Users may enter an incorrect verification code, request another OTP because the first one didn’t arrive, or try to verify a code that has already expired.
Your Node.js application must handle these situations without allowing unlimited verification attempts or repeated requests.
Incorrect or Expired OTPs
Our /verify-otp endpoint already handles incorrect codes by returning an error message and allowing the user to try again.
SecondFactor allows up to five verification attempts per code. Once the limit is reached, the API returns HTTP 409 with status: "LOCKED", and the user must request a new OTP.
If a verification has expired, the API returns HTTP 409 with status: "EXPIRED". If the verification SID isn’t found, it returns HTTP 404. In either case, your application should ask the user to request a new code rather than continue checking the old one.
Resending Verification Codes
SecondFactor doesn’t have a separate resend endpoint. To resend an OTP, your application can call the existing /send-otp endpoint with the same phone number.
Each request creates a new verification with a new code and verification SID. Our /send-otp endpoint already updates the session with the latest SID, so subsequent verification attempts will use the new code.
However, each new verification request is charged separately.
You should add a resend cooldown and enforce server-side rate limits to prevent users or automated scripts from triggering repeated requests.
Handling OTP Delivery Failures
A successful request to SecondFactor means the OTP has been queued for delivery, not necessarily that the user has received it.
If a user reports that the code hasn’t arrived, you can check the verification’s delivery status using SecondFactor’s API or configure webhooks to receive delivery updates.
SecondFactor also supports automatic fallback between eligible messaging channels when delivery isn’t confirmed, helping reduce verification failures without requiring you to build separate channel-specific retry logic.
Add Phone Verification to Your Node.js App with SecondFactor
With SecondFactor’s API, you can add phone number verification to your Node.js application without building your own OTP generation, delivery, and verification system.
SecondFactor supports OTP delivery across SMS, WhatsApp, Viber, and RCS, with automatic channel selection and fallback to help improve delivery rates while keeping verification costs down.
Sign up for SecondFactor to start sending and verifying OTPs in your Node.js application, or explore our API documentation to learn more.
