An IP risk score decides whether your login form waves someone through or throws a challenge, and the person it describes never gets to see it. Meanwhile your status page says All Systems Operational to a user whose sign-in just failed because their IP sits on a residential proxy list.

Netflix's own help page, for people who get the VPN error with their VPN off, ends with resetting the router and calling the ISP. That's the state of the art. This post builds the alternative: a small "Your connection" widget that tells visitors what your systems see about their IP, in plain language.

It drops into a hosted status page with one script tag. And it leaves out the score on purpose, because a public score checker is exactly what proxy sellers want.

TL;DR

  • Look up only the caller, on your server: read the client IP yourself and pass it to GET /v3/security. Never accept an IP from the browser.
  • Request only the fields you'll use with fields=, then send the browser sentences, not data.
  • Show flags a visitor can act on (VPN, relay, residential proxy, abuse history, corporate gateway). Hide the 0 to 100 score, every confidence value, and every last-seen date.
  • Cache per IP, limit per IP, cap the daily total, and fail quietly. Each lookup costs 2 credits.
  • Optional: a browser-side live check behind a button separates "this IP has VPN history" from "this session is on a VPN right now." The widget answers the question your support inbox keeps getting, "why am I blocked?", without turning your status page into a free proxy grader. The server keeps the score and turns flags into plain sentences. The browser only ever sees what you decided to show, and the visitor can act on all of it.

What the widget shows, and what it hides on purpose

The IP reputation API used here returns 27 fields in its security object for one address. Most of them help a person understand their own connection. A handful mainly help someone testing a proxy pool. The rule I use: show a flag when knowing it helps the visitor fix something, and hide anything that measures how sure the detection is or how recently it fired.

Field(s) What the visitor sees Why
is_vpn, vpn_provider_names "This IP is listed as a VPN exit (SurfShark VPN)" Naming the provider tells them what to switch off
is_relay, relay_provider_name "You're using iCloud Private Relay. It isn't a VPN." Relays and VPNs deserve different copy and different rules
is_tor "This IP is a Tor exit node" Honest, and the Tor user already knows
is_residential_proxy, is_proxy "Seen in residential proxy networks," plus what usually causes it The one finding innocent users rarely know about
is_spam, is_known_attacker "A recent history of abuse," plus shared-network and malware notes Common on mobile and shared IPs
is_corporate_gateway, corporate_gateway_provider_name "Your employer's security gateway (Zscaler)" Reassurance; one gateway IP fronts a whole company
is_cloud_provider, cloud_provider_name "Comes from a hosting provider (UpCloud)" The usual false positive for people on cloud desktops
threat_score Hidden. A three-state summary only The number is the gradient an attacker tunes against
*_confidence_score, *_last_seen Hidden Freshness tells a proxy operator when to rotate
is_bot, bot_type, bot_operator_name, is_known_good_bot Hidden Internal classification, meaningless to a person
proxy_provider_names Hidden Nine proxy brands is noise to a homeowner and a receipt to a proxy user
is_anonymous Not shown separately It's derived from the flags already shown

The residential proxy row is the one that justifies the whole widget. A home IP lands on those lists when something on the network is selling its bandwidth, often without the owner knowing. When Google disrupted the IPIDEA network in January 2026, its threat intelligence team described devices joining through proxy code embedded in apps, and researchers finding proxy payloads preinstalled on off-brand Android TV boxes. Those people deserve a sentence that says so.

None of this is new for VPN companies. Mullvad's connection check has printed lines like "Your IP is not blacklisted" for years. Your app can do the same for its own rules.

Why the IP risk score never leaves your server

A public IP risk check that prints a number is a tool for whoever owns the proxies. That's not hypothetical. Evomi, a residential proxy seller, lets customers append _fraudscore-0 to the proxy password so they only get exits with a clean Scamalytics score. Show a 0 to 100 number on your status page and you've built the same filter for free, tuned to your own rules.

If you only do one hardening step, drop the score. A yes/no flag tells a proxy operator one exit is burned. A number tells them which exits are almost good enough, and the confidence and last-seen fields tell them when to rotate. IPQS and Scamalytics already publish scores in their public checkers. Your site doesn't need to be another one.

Hiding a field in the UI hides nothing, because the full response sits in the browser's network tab. That rules out the shortcut the API offers. Register your status page as a Request Origin and the browser can call the security endpoint directly, no key required. Tempting for a static page, but every view costs 2 credits, the browser receives whatever you requested, and an Origin header is just a header that anything outside a browser can set. The provider's own auth docs say to prefer a backend proxy in production. That's what we'll build.

Step 1: An endpoint that only looks up the caller

Four small files: the route, the lookup, a cache with limits, and a mapper from flags to sentences. Node 18+ for the built-in fetch, Express 4 or 5, and the cors package.

Read the client IP you can actually trust

The browser can't reliably tell you its own public IP, which is why so many "javascript get client ip" answers call an echo service and post the result back to the server. Don't. An endpoint that accepts an IP from the client will look up any IP anyone sends it, on your credits. Read the address on the server.

Behind exactly one reverse proxy (nginx, a load balancer, a PaaS router), app.set("trust proxy", 1) makes req.ip the address that proxy saw, but only if every request reaches the app through that one-hop path and clients cannot reach the origin directly. If your topology has multiple paths, trust the proxy's actual IPs or CIDRs instead of a numeric hop count. Otherwise a client can supply X-Forwarded-For and turn your caller-only endpoint into an arbitrary-IP lookup.

// server.js: Node 18+, Express 4 or 5 import express from "express"; import cors from "cors"; import { lookupSecurity } from "./ipgeo.js"; import { toFindings } from "./findings.js"; import { getCached, setCached, allowRequest, spendBudget } from "./limits.js"; const app = express(); const UNAVAILABLE = { summary: "unknown", findings: [] }; // Exactly one proxy sits in front of this app. Trusting one hop makes req.ip // the address that proxy saw, not whatever the client wrote into X-Forwarded-For. app.set("trust proxy", 1); app.use(express.static("public")); // serves public/widget.js app.use("/api", cors({ origin: "https://status.example.com", methods: ["GET"] })); app.get("/api/connection", async (req, res) => { res.set("Cache-Control", "no-store"); // a per-visitor answer must never be shared by a CDN // No ?ip= parameter, ever. IPv4 clients can arrive in IPv6-mapped form. const ip = req.ip?.replace(/^::ffff:/, ""); if (!ip) return res.status(400).json(UNAVAILABLE); if (!allowRequest(ip)) { return res.status(429).json({ ...UNAVAILABLE, message: "Too many checks. Try again in a few minutes." }); } const cached = getCached(ip); if (cached) return res.json(cached); if (!spendBudget()) return res.status(503).json(UNAVAILABLE); try { const result = await lookupSecurity(ip); const body = result.private ? { summary: "unknown", findings: [{ tone: "info", text: "You're on a private network address, so there's nothing to check." }] } : { ip, ...toFindings(result.security) }; setCached(ip, body); return res.json(body); } catch (err) { // Fail open: the widget explains, it never gates. Log it, don't cache it. console.error("connection check failed:", err?.message); return res.status(503).json(UNAVAILABLE); } }); app.listen(Number(process.env.PORT ?? 3000)); 

The order matters: rate limit, then cache, then budget. A cached answer never spends credits, and a blocked caller never touches the cache.

Ask the security endpoint for only the fields you'll show

I'm using IPGeolocation's IP Security API because its response keeps relay, corporate gateway, and residential proxy as separate flags, which is exactly what plain-language copy needs. IPQS, IPinfo, and proxycheck.io all do VPN and proxy detection too, so swap freely; findings.js is the only file that knows field names. Security data needs a paid plan, and each lookup costs 2 credits.

One gotcha. If you omit ip, the endpoint looks up the caller. That's handy from a browser and useless from a server, where the caller is you. Server-side, always pass it.

curl -s "https://api.ipgeolocation.io/v3/security?apiKey=$IPGEO_API_KEY&ip=145.223.7.7" 
{ "ip": "145.223.7.7", "security": { "threat_score": 90, "is_tor": false, "is_proxy": true, "proxy_provider_names": [ "ProxyScrape", "DataImpulse", "Oxy Labs", "ProxyEmpire", "Proxy-Store", "SpyderProxy", "FleetProxy", "Proxy4u", "FlashProxy" ], "proxy_confidence_score": 99, "proxy_last_seen": "2026-10-05", "is_residential_proxy": true, "is_vpn": true, "vpn_provider_names": ["SurfShark VPN", "Ishaan VPN"], "vpn_confidence_score": 99, "vpn_last_seen": "2026-07-31", "is_relay": false, "relay_provider_name": "", "is_anonymous": true, "is_known_attacker": true, "is_bot": false, "bot_confidence_score": 0, "bot_operator_name": "", "bot_type": "", "is_known_good_bot": false, "bot_last_seen": "", "is_spam": true, "is_cloud_provider": true, "cloud_provider_name": "Brander Group Inc.", "is_corporate_gateway": false, "corporate_gateway_type": "", "corporate_gateway_provider_name": "" } } 

That's a live response from October 2026: a residential IP that's also a listed SurfShark exit, flagged for attacks and spam, with nine proxy providers attached. Every field is useful to your fraud rules. The lookup below requests 14 of the 27, and the visitor sees sentences built from 13 of those.

// ipgeo.js const API_KEY = process.env.IPGEO_API_KEY; // Only what findings.js reads. Confidence scores and last-seen dates never // reach this server, so a careless log line can't leak them either. const FIELDS = [ "security.threat_score", "security.is_vpn", "security.vpn_provider_names", "security.is_relay", "security.relay_provider_name", "security.is_tor", "security.is_proxy", "security.is_residential_proxy", "security.is_spam", "security.is_known_attacker", "security.is_cloud_provider", "security.cloud_provider_name", "security.is_corporate_gateway", "security.corporate_gateway_provider_name", ].join(","); // Throws on failure; the route's try/catch turns that into a quiet 503. export async function lookupSecurity(ip) { if (!API_KEY) throw new Error("IPGEO_API_KEY is not set"); const url = "https://api.ipgeolocation.io/v3/security" + `?apiKey=${encodeURIComponent(API_KEY)}` + `&ip=${encodeURIComponent(ip)}` + // omit this server-side and you look up yourself `&fields=${FIELDS}`; // 1.5s is plenty for a status widget. A slow answer is worse than "try again". const res = await fetch(url, { signal: AbortSignal.timeout(1500) }); // 423 Locked: a private or bogon address, i.e. you're testing from localhost or a LAN. if (res.status === 423) return { private: true }; // 401 usually means a free-plan key; security data needs a paid plan. if (!res.ok) throw new Error(`security lookup failed: HTTP ${res.status}`); const body = await res.json(); return { private: false, security: body?.security ?? null }; } 

fields here is doing privacy work, not payload trimming. Whatever this server never receives, it can't accidentally return.

Cache per IP, and cap the whole thing

Security data refreshes at least twice a day, so a 30-minute cache costs almost nothing in freshness and turns a visitor mashing the button into one lookup. Then two limits: five checks per IP per ten minutes, and a daily budget that stops spending once it's hit.

// limits.js: in-memory, single instance. Behind several instances, move all of // this to Redis or your gateway, or each instance quietly gets its own budget. const TTL_MS = 30 * 60 * 1000; // security data refreshes at least twice a day const WINDOW_MS = 10 * 60 * 1000; const PER_IP_LIMIT = 5; // checks per IP per window const DAILY_BUDGET = 5000; // lookups, i.e. 10,000 credits at 2 each const results = new Map(); // ip -> { value, expires } const hits = new Map(); // ip -> { count, resetAt } let spentToday = 0; let budgetDay = new Date().toISOString().slice(0, 10); export function getCached(ip) { const entry = results.get(ip); if (!entry) return null; if (entry.expires < Date.now()) { results.delete(ip); return null; } return entry.value; } export function setCached(ip, value) { if (results.size > 50_000) results.clear(); // crude ceiling; swap in an LRU if this ever fires results.set(ip, { value, expires: Date.now() + TTL_MS }); } export function allowRequest(ip) { const now = Date.now(); if (hits.size > 100_000) hits.clear(); // same ceiling idea for the limiter const entry = hits.get(ip); if (!entry || entry.resetAt < now) { hits.set(ip, { count: 1, resetAt: now + WINDOW_MS }); return true; } entry.count += 1; return entry.count <= PER_IP_LIMIT; } export function spendBudget() { const today = new Date().toISOString().slice(0, 10); if (today !== budgetDay) { budgetDay = today; spentToday = 0; } if (spentToday >= DAILY_BUDGET) return false; spentToday += 1; return true; } 

At 2 credits a lookup, that budget caps the widget at 10,000 credits a day no matter who's hammering it. A status page with 3,000 unique visitors who all click would use 6,000.

Be honest about what this buys. A proxy user can still check each exit once: the limit is per IP, and they have thousands of IPs. What they can't get is a score to optimize against, and they can't run your bill past the budget.

Turn flags into findings, not data

This is where the transparency actually happens, so spend your editing time on the strings, not the code. Two choices matter.

First, describe the IP, not the person. The data is the address's history, and on a shared or reassigned IP it isn't about them at all.

Second, the summary collapses the score into three states. IPGeolocation's bands run 1 to 19 low observed risk, 20 to 44 use with context, 45 to 79 elevated, and 80 to 100 high risk (what to do at each level). The widget folds the top two into one state on purpose, because "elevated" versus "high" is a gradient again.

// findings.js: flags in, sentences out. Nothing numeric leaves this function. // The copy describes YOUR policy, so edit it to match what your rules actually do. export function toFindings(security) { if (!security) return { summary: "unknown", findings: [] }; const s = security; const findings = []; const add = (tone, text) => findings.push({ tone, text }); if (s.is_corporate_gateway) { const vendor = s.corporate_gateway_provider_name || "a corporate security gateway"; add("info", `You're browsing through your employer's security gateway (${vendor}). That's normal on a work network.`); } if (s.is_relay) { add("info", `You're using ${s.relay_provider_name || "a privacy relay"}. It hides your address, but it isn't a VPN.`); } if (s.is_vpn) { const name = s.vpn_provider_names?.[0]; // IP history, not this session: an address can stay listed after it changes hands. add("warn", `This IP is listed as a VPN exit${name ? ` (${name})` : ""}. If you're not using a VPN right now, the address may have been reassigned to you.`); } if (s.is_tor) add("warn", "This IP is a Tor exit node."); if (s.is_residential_proxy) { add("warn", "This IP has been seen in residential proxy networks. If you're not running a proxy, check for 'bandwidth sharing' apps, free VPN apps, or no-name streaming boxes on your network."); } else if (s.is_proxy) { add("warn", "This IP has been seen working as a proxy."); } if (s.is_spam || s.is_known_attacker) { add("warn", "This IP has a recent history of abuse. On mobile or shared networks that's usually someone else's traffic. On a home connection, a malware scan is worth ten minutes."); } // Skip "hosting" when another finding already explains the address, or the copy contradicts itself. if (s.is_cloud_provider && !s.is_corporate_gateway && !s.is_residential_proxy) { add("info"...
Source:https://dev.to/abdullah_afzal/ip-risk-score-widget-show-visitors-what-you-see-47m2