Multi-signal signup fraud prevention: phone, email, and IP
One weak signal blocks nothing; here's how to fold email, phone, and IP checks into a single risk score — and exactly where Boundstone's honesty ends.
Contents
Search "signup fraud prevention" and you'll drown in vendors selling a single magic score. The honest version is duller and more useful: no one signal catches a determined abuser, and every signal has a failure mode. What works is combining a few cheap, fast checks — email, phone, and IP — into a risk number your own policy decides how to act on. This walks through exactly that, and it's equally clear about what these checks can't see.
One signal is a coin flip
A disposable email address is suspicious. It is also what a privacy-conscious real user reaches for. A number that fails to parse is a red flag — and so is a legitimate customer fat-fingering their area code. A private source IP means something is off with how the request reached you — or your load balancer just isn't forwarding the client address.
Each signal, alone, produces false positives you'll regret. Together they're a different story: real users rarely trip three independent checks at once, while bulk-created fake accounts tend to. The goal isn't a verdict from any one field. It's a score.
Three cheap signals worth combining
Email: disposable and role, not just "looks like an email"
Your language already validates email shape. A regex, Python's email.utils.parseaddr, or the browser's type="email" gets you syntax for free — often all you need at the form layer. Say so.
Where a stdlib stops is the domain. Is it a throwaway from a disposable provider? Does it even have an MX record to receive mail? Is it a role address like admin@ or support@ that rarely belongs to one human? POST /v1/verify/email answers those over one contract and hands you the receipts:
{
"valid_syntax": true,
"domain": "mailinator.com",
"mx_found": true,
"disposable": true,
"role_account": false,
"free_provider": false,
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
}
Read not_performed before you trust disposable: true. Boundstone does not open an SMTP session to confirm the mailbox exists, and does not detect catch-all domains. It checks shape, MX, and known lists — nothing it can't stand behind. You can try this without an account at /tools/email-validator. And note free_provider: a Gmail address is not fraud. It's a returned flag, deliberately kept out of performed, because "uses a free provider" is context, not a defect.
Phone: plausibility, not liveness
POST /v1/verify/phone tells you whether a number is well-formed for its region and what line type the metadata suggests:
{
"valid": true,
"e164": "+16504472983",
"country": "US",
"line_type": "fixed_line_or_mobile",
"national_format": "(650) 447-2983",
"checks": {
"performed": ["format", "region", "line_type_metadata"],
"not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
}
}
That's plausibility, and it's genuinely useful at signup — an unparseable number is a real signal. What it is not is proof the phone rings. Carrier lookup, ported-status, and HLR liveness are not_performed by default; they run as a paid opt-in with hlr:true, priced at 5 credits and refunded when the network cannot answer. Treat valid: true as "this could be a real number," not "someone answered it."
IP: private, bogon, loopback as a red flag
POST /v1/verify/ip classifies the source address by IANA range — public, private, loopback, reserved, bogon:
{
"valid": true,
"version": 4,
"normalized": "10.0.0.7",
"classification": "private",
"is_public": false,
"is_bogon": false,
"checks": {
"performed": ["format", "version", "range_classification"],
"not_performed": ["geolocation", "asn", "hosting_datacenter", "proxy_vpn_tor", "reputation"]
}
}
A signup whose source IP is private, loopback, or bogon is worth a second look — a real browser on the public internet doesn't arrive from 10.0.0.7. Usually it means a misconfigured proxy header; sometimes something worse. Either way it's a cheap flag.
What Boundstone does not do
Look hard at that IP not_performed list, because it names the things people expect and Boundstone won't fake: no geolocation, no ASN, no hosting/datacenter detection, no proxy/VPN/Tor detection, no reputation. Those need licensed data that isn't shipped. Boundstone also does not do device fingerprinting.
Those are separate, heavier problems, and mature fraud platforms exist for them. If your threat model needs "is this a known VPN exit node in a high-risk region on a reused device," that's a different tool. Boundstone gives you fast, honest structural signals — the layer you build a policy on, not the whole policy.
A signup handler that scores all three
Here's a Node handler making the three calls in parallel and folding the results into one number. Node 18+ has fetch built in.
import express from "express";
const app = express();
app.use(express.json());
const API = "https://api.boundstone.io";
const KEY = process.env.BOUNDSTONE_API_KEY;
async function verify(kind, body) {
const res = await fetch(`${API}/v1/verify/${kind}`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return res.json();
}
function score({ email, phone, ip }) {
let risk = 0;
const reasons = [];
if (email.disposable) { risk += 3; reasons.push("disposable email domain"); }
if (!email.mx_found) { risk += 2; reasons.push("email domain has no MX"); }
if (email.role_account) { risk += 1; reasons.push("role address"); }
if (!phone.valid) { risk += 3; reasons.push("phone did not parse"); }
if (!ip.is_public) { risk += 3; reasons.push(`non-public source IP (${ip.classification})`); }
if (ip.is_bogon) { risk += 2; reasons.push("bogon source IP"); }
return { risk, reasons };
}
app.post("/signup", async (req, res) => {
const { email, phone } = req.body;
const ip =
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
req.socket.remoteAddress;
const [emailResult, phoneResult, ipResult] = await Promise.all([
verify("email", { email }),
verify("phone", { phone }),
verify("ip", { ip }),
]);
const { risk, reasons } = score({
email: emailResult,
phone: phoneResult,
ip: ipResult,
});
if (risk >= 6) return res.status(403).json({ decision: "block", reasons });
if (risk >= 3) return res.status(202).json({ decision: "review", reasons });
return res.status(201).json({ decision: "allow" });
});
The weights are yours to tune — they encode your tolerance, not a universal truth.
Flags are inputs, not verdicts
The one rule that keeps this honest: a flag is an input to a policy, never the policy itself. disposable: true doesn't ban a user; it adds three points. Three signals agreeing is what earns a block. Everything in between — step-up email confirmation, a review queue, a soft limit on the new account — is where most fraud actually gets handled without torching real signups. Because Boundstone returns performed and not_performed on every response, you always know exactly which evidence your score is standing on.
The short version
- No single check stops signup fraud. Combine email, phone and IP into a score.
- Lead with your stdlib for syntax. Use the API for disposable/role/MX, phone plausibility and IP classification under one contract.
- Boundstone does not do IP intelligence. No geolocation, ASN, hosting/datacenter, VPN/proxy/Tor, reputation or device fingerprinting — those need licensed data or a dedicated platform.
- Read
not_performedbefore trusting anyvalid. That list is why a "valid" means something. - Turn scores into graduated policy — block, review, allow — not a single kill switch.
Wire it up with the signup-fraud use-case guide and the full API docs. The free tier is 250 credits a month, no card, and credits never expire — enough to test all three signals against your own traffic before you commit to anything.