Someone signs up for your free trial, burns the credits, and comes back an hour
later as a new account. Or 400 accounts claim the same referral bonus. Or your
"unique visitors" number is 30% datacenter traffic.
All three start the same way: the request came from an IP the user does not
actually live behind. Here's how to check that in Node, in one call, for free.
Why the obvious approaches don't work
Blocking by ASN. You grab a list of hosting ASNs, block AWS, DigitalOcean,
Hetzner. This catches the laziest 20% and nothing else. Commercial VPNs
increasingly exit through leased residential IPs, and residential proxy
networks are literally other people's home connections — real ISPs, real
cities, clean reputation.
Rate limiting by IP. Attackers rotate through pools of hundreds of
thousands of IPs. Rate limiting is a speed bump for volume, not a signal about
identity.
GeoIP mismatch. "Card is French, IP is Dutch" produces enough false
positives from ordinary travellers and corporate VPNs that most teams turn the
rule off within a month.
What you actually want is a direct answer to: is this IP a tunnel, and what
kind? That's a different question from "where is this IP," and it needs a feed
of live tunnel data, not a geolocation database.
One call
curl -X POST https://maskbreak.com/api/lookup \
-H 'Content-Type: application/json' \
-d '{"ip":"185.220.101.1"}'
{
"ip": "185.220.101.1",
"known": true,
"verdict": "block",
"risk_score": 90,
"signals": {
"vpn": false,
"proxied": false,
"tor": true,
"dch": false,
"anon": true
},
"network": { "asn": null, "org": "Tor exit node", "country": null, "city": null }
}
A clean IP comes back small and boring:
{"ip":"8.8.8.8","known":false,"verdict":"allow","risk_score":0,"signals":null,"network":null}
known: false means nothing flagged it. That's the answer you want for the
overwhelming majority of your real users, and it's the reason this doesn't have
to be a scary thing to put in front of signup.
The signals, and what they actually mean
| Signal | Meaning | Weight |
|---|---|---|
vpn |
Commercial VPN exit | +60 |
proxied |
Proxy, including residential proxy networks | +60 |
tor |
Tor exit node | floor of 90 |
dch |
Datacenter / hosting range | +40 |
anon |
Anonymised, method not otherwise classified | +30 |
Score is clamped to 100, and the verdict follows: >=80 block, >=40 review,
else allow.
Note that dch alone is 40 — a review, not a block. That's deliberate.
Datacenter traffic is your own monitoring, someone's corporate egress, a
security researcher, an AI crawler. It's worth a second look and it is not
worth a hard block on its own. The hard blocks are tunnels.
Also note dch and vpn are independent, and this is the part naive
implementations get wrong: VPN exits live in datacenters. If your logic is
if (datacenter) return block, you never learn which datacenter IPs are
tunnels, and you've collapsed two different decisions into one bad one.
Express middleware
const express = require('express');
const app = express();
app.set('trust proxy', true); // behind a proxy/CDN, or req.ip is your LB
async function ipRisk(ip) {
const r = await fetch('https://maskbreak.com/api/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip }),
signal: AbortSignal.timeout(1500)
});
if (!r.ok) throw new Error(`lookup ${r.status}`);
return r.json();
}
async function riskCheck(req, res, next) {
try {
const result = await ipRisk(req.ip);
req.risk = result;
if (result.verdict === 'block') {
return res.status(403).json({ error: 'Signup unavailable from this network.' });
}
if (result.verdict === 'review') {
req.requireEmailVerification = true; // friction, not a wall
}
next();
} catch (err) {
// FAIL OPEN. A dead risk check must never take down signup.
console.error('[risk]', err.message);
next();
}
}
app.post('/signup', express.json(), riskCheck, async (req, res) => {
// ...create the account, honouring req.requireEmailVerification
});
Three things in there are the whole lesson:
Fail open. Your fraud check is a third-party network call in the hot path of
your most important conversion funnel. If it times out, users sign up. A risk
service that can 500 your signup page is a worse problem than the fraud it
prevents.
Set a timeout. AbortSignal.timeout(1500) — Node's default fetch has no
timeout at all, so without this a hung connection hangs the request until the
socket dies.
Two outcomes, not one. block and review are different. review means
add friction — email verification, a hold on payout, manual approval — not a
- Most of your fraud lives in the review band, and most of your false positives do too, which is exactly why it should cost the user a step rather than the account.
Persist the result. Store signals and risk_score next to the account
row. In a month you can query which signals actually preceded chargebacks in
your product and re-tune from data instead of from a blog post, including
this one.
The keyed endpoint
The keyless POST /api/lookup above is rate limited for casual use. With a
free key you get GET /v1/lookup/{ip} at 1,000 requests/hour:
const r = await fetch(`https://maskbreak.com/v1/lookup/${ip}`, {
headers: { Authorization: `Bearer ${process.env.SENTINEL_KEY}` }
});
Or the SDK, which is zero-dependency and runs anywhere fetch exists — Node
18+, Bun, Deno, Workers, Vercel Edge:
npm install @sentinelsup/sdk
The key is free, there's no card, and it doesn't expire — Maskbreak
is free at 1,000 req/hour with every signal included. Full docs at
maskbreak.com/api.
Where IP checks stop working
Be honest about the ceiling: IP intelligence catches tunnels. It does not catch
a determined attacker on a clean residential IP using an antidetect browser to
farm accounts. For that you need device-level signals — the fingerprint that
survives a new IP and a cleared cookie jar.
IP is the cheapest, fastest layer, it's one call, and it removes most of the
volume. It's the right first thing to ship. It just isn't the last thing.
Top comments (2)
The dch/vpn split gets messier once you poll more than one feed. ip-api and proxycheck both flag entire AWS and Hetzner ranges as proxy, so two sources "agreeing" is often the same datacenter guess counted twice. I require two dedicated proxy feeds before flagging a datacenter IP, one for residential. Known VPN ASNs like M247 stand alone.
The dch/vpn independence point is the one most people get wrong, and you called it out clearly — collapsing "datacenter" and "tunnel" into one block rule is a classic false-positive generator (kills legitimate crawler/monitoring traffic for nothing). I've got a lighter-weight version of this in one of my endpoints (basic VPN/EU flag alongside geolocation, no scored verdict), and reading this makes me want to add a review-band instead of a binary flag — the "friction, not a wall" framing for the 40-79 range is a genuinely useful design choice I hadn't considered.