How to Approve Real Users and Block Fake Accounts at Sign Up with Lookup and Verify

March 16, 2026
Written by
Reviewed by

How to approve real users & block fake accounts at sign up with Lookup & Verify

By implementing onboarding intelligence with Twilio Lookup and phone verification with Twilio Verify, you can build seamless sign ups and higher pass rates while still blocking fraud. Combining multiple fraud checks like detecting line type and proving phone number possession into one flow creates a resilient yet frictionless defense layer to block fake accounts while ensuring a smooth path for real users.

By the end of this tutorial you will have a working JavaScript example that can collect a user's name and phone number and conduct a multi-step orchestrated identity verification flow. You can also find the completed code on GitHub.

Prerequisites to building with Twilio Lookup and Verify

To code along with this post you will need:

curl -X POST "https://verify.twilio.com/v2/Services" \
  --data-urlencode "FriendlyName=My Verify Service" \
  -u $TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN

If you're testing outside of the US and Brazil, you may see Error 60619: Lookup Request Cannot be Completed in Twilio Region. To bypass this, you will need carrier approval for Lookup Identity Match. Alternatively, you can use our sandbox experience with test credentials and magic numbers.

Set up your Node.js project

Now start your project to build a trusted sign up flow:

mkdir twilio-trusted-signups
cd twilio-trusted-signups
npm init -y
npm install express twilio dotenv ejs

Create a .env file and add the following keys:

TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Building the Verification Pipeline

This project will codify 4 layers of checks on a phone number during sign up. The best part is that the user won't know 3 of them are happening and they get progressively more intense so we're filtering out bad actors faster and cheaply before taking more drastic actions.

Here's a look at what we're building:

Flowchart showing steps for verifying user identity, from form submission to approval or rejection.

Process flow diagram for orchestrating onboarding intelligence

Step 1 - Check the line type

First, use the Lookup API line type intelligence package to make sure we're dealing with a mobile number. The code explicitly filters out landlines, nonfixed VoIP, toll free, (and pagers for fun) but you can customize this easily. Learn more about potential line types the API can return in the documentation.

Step 2 - [Optional] Check the line status

Then make sure the line is reachable. Use the Lookup API line status package to filter out inactive and unreachable numbers. Note - this is commented out by default in the code below since it is in Private Beta and requires an extra step to get access. To request access for Lookup Line Status, submit this form.

Step 3 - Match the name to the phone number

In the last of our background checks, use the Lookup API Identity Match package to verify that the submitted name matches the phone number. Identity Match compares user-supplied data against authoritative sources for a zero-knowledge result, in other words a way to verify the data’s accuracy without revealing the underlying data. Check the individual first_name_match and last_name_match fields directly and require each to be either exact_match or high_partial_match. This allows common variations like nicknames or middle names used as a first name, while still rejecting mismatches. Any other result (a no_match, partial_match, or null) will reject the request.

You can change these requirements to fit your business logic or reduce false negatives. Learn more about Identity Match field values in the documentation.

Step 4 - Phone verification

If all of the lookup steps pass, the user will receive an OTP and complete a standard phone verification flow.

To implement all four steps, copy the following code into a new file called "index.js":

require("dotenv").config();
const express = require("express");
const twilio = require("twilio");
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, VERIFY_SERVICE_SID } =
 process.env;
if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !VERIFY_SERVICE_SID) {
 throw new Error(
   "Missing required env vars. Check TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, VERIFY_SERVICE_SID.",
 );
}
const PORT = process.env.PORT || 3000;
const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);
const app = express();
app.use(express.urlencoded({ extended: false }));
app.set("view engine", "ejs");
function logStep(steps, label, detail, passed) {
 steps.push({ label, detail, passed });
 console.log(passed ? "✅" : "❌", label + ":", detail);
}
function normalize(str) {
 return String(str || "")
   .trim()
   .toLowerCase();
}
async function runOnboardingIntelligence({ phoneNumber, firstName, lastName }) {
 const lookup = (fields, params = {}) =>
   client.lookups.v2.phoneNumbers(phoneNumber).fetch({ fields, ...params });
 const steps = [];
 // ---- 1) Line Type Intelligence
 let lti;
 try {
   lti = await lookup("line_type_intelligence");
 } catch (e) {
   return { ok: false, reason: "LOOKUP_FAILED", detail: e.message, steps };
 }
 const lineType = normalize(lti?.lineTypeIntelligence?.type);
 // https://www.twilio.com/docs/lookup/v2-api/line-type-intelligence#type-property-values
 const blockedLineTypes = new Set(["landline", "nonfixedvoip", "tollfree", "pager"]);
 const ltiPassed = !blockedLineTypes.has(lineType);
 logStep(steps, "Line Type Intelligence", lineType, ltiPassed);
 if (!ltiPassed) return { ok: false, reason: "LINE_TYPE_BLOCKED", steps };
 // ---- 2) Line Status
 // Uncomment once you have access to the package: https://docs.google.com/forms/d/e/1FAIpQLSfXowQ9dUGgDNc_onA0yj2_Mo3tXxFWK67SpDfOZjONothBYQ/viewform
 // let ls;
 // try {
 //   ls = await lookup("line_status");
 // } catch (e) {
 //   return { ok: false, reason: "LOOKUP_FAILED", detail: e.message, steps };
 // }
 // const lineStatus = normalize(ls?.lineStatus?.status);
 // const lsPassed = lineStatus !== "inactive" && lineStatus !== "unreachable";
 // logStep(steps, "Line Status", lineStatus, lsPassed);
 // if (!lsPassed) return { ok: false, reason: "LINE_STATUS_BLOCKED", steps };
 // ---- 3) Identity Match
 let im;
 try {
   im = await lookup("identity_match", { firstName, lastName });
 } catch (e) {
   return { ok: false, reason: "LOOKUP_FAILED", detail: e.message, steps };
 }
 const imErrorCode = im?.identityMatch?.error_code;
 if (imErrorCode) {
   logStep(steps, "Identity Match", `unavailable (error_code: ${imErrorCode})`, false);
   return { ok: false, reason: "IDENTITY_MATCH_UNAVAILABLE", steps };
 }
 const acceptedMatches = new Set(["exact_match", "high_partial_match"]);
 const firstNameMatch = im?.identityMatch?.first_name_match;
 const lastNameMatch = im?.identityMatch?.last_name_match;
 const imPassed = acceptedMatches.has(firstNameMatch) && acceptedMatches.has(lastNameMatch);
 logStep(steps, "Identity Match", `first: ${firstNameMatch}, last: ${lastNameMatch}`, imPassed);
 if (!imPassed) return { ok: false, reason: "IDENTITY_MATCH_FAILED", steps };
 return { ok: true, steps };
}
// -------------------- Routes --------------------
app.get("/", (req, res) => {
 res.render("index", { page: "signup", title: "Signup" });
});
app.post("/start", async (req, res) => {
 const phoneNumber = (req.body.phoneNumber || "").trim();
 const firstName = (req.body.firstName || "").trim();
 const lastName = (req.body.lastName || "").trim();
 if (!phoneNumber || !firstName || !lastName) {
   return res.status(400).render("index", { page: "rejected", title: "Error", reason: "MISSING_FIELDS" });
 }
 const gate = await runOnboardingIntelligence({
   phoneNumber,
   firstName,
   lastName,
 });
 if (!gate.ok) {
   // boolean pass/fail + reason code (rendered)
   return res.status(403).render("index", { page: "rejected", title: "Rejected", reason: gate.reason, steps: gate.steps });
 }
 // ---- 4) Verify: send OTP
 try {
   console.log("Sending OTP to:", phoneNumber);
   await client.verify.v2
     .services(VERIFY_SERVICE_SID)
     .verifications.create({ to: phoneNumber, channel: "sms" });
 } catch (e) {
   console.log(phoneNumber, firstName, lastName);
   console.error("Error sending OTP:", e);
   return res.status(500).render("index", { page: "rejected", title: "Error", reason: "OTP_SEND_FAILED" });
 }
 res.render("index", { page: "verify", title: "Verify", phoneNumber, steps: gate.steps });
});
app.post("/check", async (req, res) => {
 const phoneNumber = (req.body.phoneNumber || "").trim();
 const code = (req.body.code || "").trim();
 if (!phoneNumber || !code) {
   return res.status(400).render("index", { page: "rejected", title: "Error", reason: "MISSING_FIELDS" });
 }
 try {
   const check = await client.verify.v2
     .services(VERIFY_SERVICE_SID)
     .verificationChecks.create({ to: phoneNumber, code });
   if (check.status === "approved") {
     return res.render("index", { page: "approved", title: "Approved" });
   }
   return res.status(401).render("index", { page: "rejected", title: "Rejected", reason: "OTP_INVALID" });
 } catch (e) {
   return res.status(500).render("index", { page: "rejected", title: "Error", reason: "OTP_CHECK_FAILED" });
 }
});
app.listen(PORT, () => {
 console.log(`Server running: http://localhost:${PORT}`);
});

Then create a new file called "views/index.ejs" where we'll render a very basic UI to collect a phone number, first name, and last name:

<!doctype html>
<html>
<head>
 <meta charset="utf-8" />
 <title><%= title %></title>
 <% if (page === "signup") { %>
 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.4/build/css/intlTelInput.css" />
 <% } %>
</head>
<body>
 <% if (page === "signup") { %>
 <h1>Signup</h1>
 <form id="signup-form" method="POST" action="/start">
  <label>Phone: <input id="phone" type="tel" /></label><br/>
  <label>First name: <input name="firstName" /></label><br/>
  <label>Last name: <input name="lastName" /></label><br/>
  <button type="submit">Submit</button>
 </form>
 <script src="https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.4/build/js/intlTelInput.min.js"></script>
 <script>
   const input = document.querySelector("#phone");
   const form = document.querySelector("#signup-form");
   const iti = intlTelInput(input, {
     initialCountry: "us",
     hiddenInput: () => ({ phone: "phoneNumber" }),
     loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@25.12.4/build/js/utils.js"),
   });
   form.addEventListener("submit", (e) => {
     if (!iti.isValidNumber()) {
       e.preventDefault();
       alert("Please enter a valid phone number.");
     }
   });
 </script>
 <% } else if (page === "verify") { %>
 <h1>Enter OTP</h1>
 <ul>
   <% steps.forEach(step => { %>
   <li><%= step.passed ? '✅' : '❌' %> <strong><%= step.label %>:</strong> <code><%= step.detail %></code></li>
   <% }) %>
 </ul>
 <p>We sent a code to <code><%= phoneNumber %></code>.</p>
 <form method="POST" action="/check">
  <input type="hidden" name="phoneNumber" value="<%= phoneNumber %>" />
  <label>Code: <input name="code" /></label><br/>
  <button type="submit">Verify</button>
 </form>
 <% } else if (page === "approved") { %>
 <h1>Approved</h1>
 <p><a href="/">Back</a></p>
 <% } else if (page === "rejected") { %>
 <h1><%= title %></h1>
 <p>reason: <code><%= reason %></code></p>
 <% if (typeof steps !== 'undefined' && steps.length) { %>
 <ul>
   <% steps.forEach(step => { %>
   <li><%= step.passed ? '✅' : '❌' %> <strong><%= step.label %>:</strong> <code><%= step.detail %></code></li>
   <% }) %>
 </ul>
 <% } %>
 <p><a href="/">Back</a></p>
 <% } %>
</body>
</html>

Run and test the code

Save your files and run the project with:

 

node index.js

 

Open http://localhost:3000 and test it out with your personal mobile number. You should see logs like:

Server running: http://localhost:3000
✅ Line Type Intelligence: mobile
✅ Line Status: active
✅ Identity Match: first: exact_match, last: exact_match
Sending OTP to: +1**********

At this point you may get Error 60619: Lookup Request Cannot be Completed in Twilio Region. Learn more about availability by country and request access here. Alternatively, you can use our sandbox experience with test credentials and magic numbers.

You can also test with a toll free or VoIP number like +17739857836 and you'll see an error with LINE_TYPE_BLOCKED. Or use your real phone number but with a different name and see Identity Match fail. Testing all possible outcomes of line status and identity match is a little tricker, so we recommend using test credentials and magic numbers.

Pricing considerations and next steps

One of the reasons this is 4 different API calls is that we want to be considerate of price. Line Type Intelligence and Line Status are the cheapest Lookup packages, while Identity Match and Verify are more expensive. Learn more about Lookup pricing (varies by country) and rearrange the steps to fit your use case.

Bundling Lookup and Verify is a great way to filter out unwanted bots, fake accounts, and reduce sign up fraud. It also allows you to validate real users seamlessly. For more information, check out:

I can't wait to see what you build and secure.