VPN, proxy, and Tor detection in Node.js
Production-grade Node.js integration of the IPLogs public API: typed responses, Express middleware, fetch with timeouts, per-IP caching with LRU or Redis, batch scoring via /v1/bulk-check, and tests that don't touch the network. No API key, no signup.
Type the response
Catch shape drift at compile time and get IDE autocomplete by declaring a small set of types up front:
export type Verdict =
| "clean"
| "suspicious"
| "vpn_likely"
| "vpn_detected";
export interface IPInfo {
ip: string;
asn?: string;
org?: string;
country?: string;
country_code?: string;
is_vpn?: boolean;
vpn_provider?: string;
vpn_provider_sources?: string[];
}
export interface Signal {
type: string;
weight: number;
matched: boolean;
detail: string;
}
export interface CheckResponse {
verdict: Verdict;
score: number;
is_vpn: boolean;
confidence: number;
signals: Signal[];
ip_info: IPInfo;
request_id: string;
}Quick start (built-in fetch + AbortSignal)
import type { CheckResponse } from "./types";
export async function checkVpn(ip: string): Promise<CheckResponse> {
const r = await fetch("https://iplogs.com/v1/check", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ip }),
signal: AbortSignal.timeout(5_000),
});
if (!r.ok) throw new Error(`IPLogs HTTP ${r.status}`);
return r.json() as Promise<CheckResponse>;
}AbortSignal.timeout(5000) caps a single call at five seconds so a slow upstream cannot pin a worker. The server-side budget for /v1/check is around eight seconds, so a five-second client timeout will fire only when something is genuinely wrong.
Reading the visitor IP from a trusted proxy
Behind a reverse proxy the client IP arrives in a header. Read the right one for your edge and never trust a header from an untrusted source.
import type { Request } from "express";
export function clientIp(req: Request): string {
// Cloudflare
const cf = req.header("cf-connecting-ip");
if (cf) return cf.trim();
// Caddy / Nginx append the real peer last; read the LAST entry
const xff = req.header("x-forwarded-for");
if (xff) {
const parts = xff.split(",");
return parts[parts.length - 1].trim();
}
return req.ip ?? "";
}Express middleware
import type { Request, Response, NextFunction } from "express";
import { checkVpn } from "./check-vpn";
import { clientIp } from "./client-ip";
import type { CheckResponse } from "./types";
declare module "express-serve-static-core" {
interface Locals { verdict?: CheckResponse }
}
export async function vpnVerdictMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const ip = clientIp(req);
if (!ip) return next();
try {
res.locals.verdict = await checkVpn(ip);
} catch {
// Fail open: leave verdict undefined; per-route handlers decide.
}
next();
}
// Per-route policy
app.post("/signup", vpnVerdictMiddleware, (req, res) => {
const v = res.locals.verdict?.verdict;
if (v === "vpn_detected") return res.status(403).end();
if (v === "vpn_likely") return res.redirect("/signup/verify");
return handleSignup(req, res);
});Per-IP cache (in-process or Redis)
A 5-minute per-IP cache is the right default. In-process LRU for a single instance, Redis for a fleet so the cache is shared.
import { LRUCache } from "lru-cache";
import { checkVpn } from "./check-vpn";
import type { CheckResponse } from "./types";
const cache = new LRUCache<string, CheckResponse>({
max: 10_000,
ttl: 5 * 60 * 1000, // 5 minutes
});
export async function checkVpnCached(ip: string): Promise<CheckResponse> {
const hit = cache.get(ip);
if (hit) return hit;
const v = await checkVpn(ip);
cache.set(ip, v);
return v;
}Redis variant: substitute the cache with ioredis and serialize the verdict as JSON with EX 300. Use the IP as the key with no per-deploy prefix so the cache survives rollouts.
Batch scoring with /v1/bulk-check
export interface BulkRow {
ip: string;
verdict: Verdict;
score: number;
is_vpn: boolean;
vpn_provider?: string;
country_code?: string;
asn?: string;
org?: string;
error?: string;
}
export async function bulkCheck(ips: string[]): Promise<BulkRow[]> {
const r = await fetch("https://iplogs.com/v1/bulk-check", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ips }),
signal: AbortSignal.timeout(95_000), // server budget ~90s
});
if (!r.ok) throw new Error(`bulk HTTP ${r.status}`);
const json = await r.json() as { results: BulkRow[] };
return json.results;
}
// Chunk a larger list into batches of 100
export async function scoreQueue(allIps: string[]): Promise<Record<string, BulkRow>> {
const out: Record<string, BulkRow> = {};
for (let i = 0; i < allIps.length; i += 100) {
const batch = await bulkCheck(allIps.slice(i, i + 100));
for (const row of batch) out[row.ip] = row;
}
return out;
}Testing without hitting the network
// vitest
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkVpn } from "./check-vpn";
afterEach(() => vi.restoreAllMocks());
describe("checkVpn", () => {
it("returns the verdict for a clean IP", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({
verdict: "clean",
score: 0,
is_vpn: false,
confidence: 0.15,
signals: [],
ip_info: { ip: "1.1.1.1" },
request_id: "req_test",
})),
);
const out = await checkVpn("1.1.1.1");
expect(out.verdict).toBe("clean");
});
});FAQ
Do I need an API key to use IPLogs from Node.js?
No. The /v1/check endpoint is public and unauthenticated, so a single fetch from any Node.js process works. There is no signup or token. Fair-use throttling may apply for sustained high volume — contact admin@iplogs.com for predictable capacity.
Should I use undici, axios, got, or the built-in fetch?
Built-in fetch (Node 18+) is the right default — it's bundled, has AbortSignal.timeout, and is the same API the browser ships. undici is the underlying engine and gives you connection pooling primitives if you need them. axios and got add features you mostly don't need for one POST to one host.
Where should the IPLogs call live in an Express app?
Behind an Express middleware that runs the check, stores the verdict on res.locals.verdict, and lets per-route handlers decide what to do. That keeps policy code colocated with the route while the network call lives in one place.
Does Next.js middleware support this kind of call?
Yes for Edge middleware that can run fetch, but Edge has a tight time budget. The cleaner pattern is to call /v1/check from a server component, a Route Handler, or your Node.js backend, and pass the verdict down via headers or context rather than blocking Edge middleware on a network round-trip.
How do I test the integration without hitting the network?
Mock fetch with vitest's vi.spyOn(globalThis, 'fetch') or use undici's MockAgent for richer matching. The verdict shape is stable: verdict ('clean' | 'suspicious' | 'vpn_likely' | 'vpn_detected'), score (0–1), is_vpn, ip_info, signals[]. Asserting on the URL and body covers the integration.
See the API docs for the full response schema, the Python guide for the equivalent integration, and the accuracy benchmark for the published false-positive rate. Try a request from your browser: live IP checker.