Blog

How to detect a VPN by IP address in Node.js

You want to know whether a request is coming from a VPN, a proxy, or a datacenter IP — and respond appropriately. The good news: it's one API call. The important news: a VPN flag is a signal, not a verdict, and treating it like one will hurt real users.

This guide builds a small, production-shaped Express integration that checks each request, exposes the risk on req.ipRisk, and steps up verification for high-risk signups — without ever silently blocking someone for using a VPN.

1. The raw call

Every lookup is a single authenticated GET:

$ curl "https://api.geoq.io/v1/check?ip=45.83.220.5" \
    -H "Authorization: Bearer $GEOQ_API_KEY"

That returns the full payload: signals.is_vpn, is_proxy, is_datacenter, geo, ASN and a risk object. See the response schema.

2. A tiny wrapper

Wrap the call so the rest of your app never touches fetch directly:

// geoq.js — a tiny wrapper
export async function checkIp(ip) {
  const url = new URL('https://api.geoq.io/v1/check');
  if (ip) url.searchParams.set('ip', ip);
  const res = await fetch(url, {
    headers: { Authorization: 'Bearer ' + process.env.GEOQ_API_KEY },
  });
  if (!res.ok) throw new Error('geoq ' + res.status);
  return res.json();
}

3. Middleware that fails open

Attach the result to the request. Critically, if the API has a blip we fail open — a missed risk check is far better than locking every user out:

import express from 'express';
import { checkIp } from './geoq.js';

const app = express();
app.set('trust proxy', true); // so req.ip is the real client IP

// Flag risky IPs, but step up — never silently block.
app.use(async (req, res, next) => {
  try {
    const r = await checkIp(req.ip);
    req.ipRisk = r;
    if (r.signals.is_vpn || r.signals.is_proxy) {
      res.set('X-IP-Note', 'vpn-or-proxy');
    }
  } catch (e) {
    req.ipRisk = null; // fail open — don't lock users out on an API blip
  }
  next();
});
Setting trust proxy matters: behind a load balancer, req.ip is otherwise your proxy's IP, not the user's. Use the leftmost untrusted value from X-Forwarded-For.

4. Step up, don't block

Now use the risk level in a route. A VPN user is usually a normal user who cares about privacy. Add friction proportional to risk instead of denying access:

app.post('/signup', async (req, res) => {
  const risk = req.ipRisk?.risk;
  if (risk && risk.level === 'high') {
    // require email verification before creating the account
    return res.status(202).json({ next: 'verify-email', reasons: risk.reasons });
  }
  // ...create the account normally
  res.json({ ok: true });
});

What the VPN signal can and can't tell you

  • Can: tell you an IP belongs to a known commercial VPN range — useful context, worth +30 in the risk score.
  • Can't: tell you the person is malicious. Journalists, remote workers and the privacy-conscious all use VPNs.
  • Won't be perfect: VPN providers rotate ranges constantly, so detection is probabilistic.

That's why we combine is_vpn with the overall risk score and only escalate to step-up auth, never to a hard block — and why GeoQ must not be the sole basis of an automated decision about a person (AUP).

Next steps

Read the VPN detection API page, try the IP lookup tool, or get a free API key (no card) and ship this today.

Signals are probabilistic, not facts. Don't make a sole-basis automated decision about a person — see the acceptable use policy.

Keep reading

Start with the free tier. No card.

1,000 lookups a day, every signal, the same transparent risk score. Upgrade only when you outgrow it.