DEV Community

Cover image for IP Risk Score Widget: Show Visitors What You See
ABDULLAH AFZAL
ABDULLAH AFZAL

Posted on

IP Risk Score Widget: Show Visitors What You See

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));
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
{
  "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": ""
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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", `This connection comes from a hosting provider${s.cloud_provider_name ? ` (${s.cloud_provider_name})` : ""}, not a home or mobile network.`);
  }

  const rawScore = s.threat_score;
  const score = Number(rawScore);
  const hasScore =
    rawScore !== null &&
    rawScore !== undefined &&
    rawScore !== "" &&
    Number.isFinite(score);

  const summary = !hasScore
    ? "unknown"
    : score >= 45
      ? "attention"
      : score >= 20
        ? "notice"
        : "clear";
    return { summary, findings };
}
Enter fullscreen mode Exit fullscreen mode

For 145.223.7.7, this is everything the browser receives:

{
  "ip": "145.223.7.7",
  "summary": "attention",
  "findings": [
    { "tone": "warn", "text": "This IP is listed as a VPN exit (SurfShark VPN). If you're not using a VPN right now, the address may have been reassigned to you." },
    { "tone": "warn", "text": "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." },
    { "tone": "warn", "text": "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." }
  ]
}
Enter fullscreen mode Exit fullscreen mode

No score, no confidence, no dates, no proxy brands. Brander Group's hosting flag got dropped too, because "residential proxy" and "not a home network" in the same card would read as a bug.

Step 2: The widget is one script tag

The widget runs on click, not on page load. Status pages get hit by uptime monitors, link previews, and crawlers, and none of them need a lookup. It renders with textContent only, because provider names are third-party strings and innerHTML turns them into a script injection waiting to happen.

// public/widget.js: loaded by the status page, talks only to your endpoint.
(() => {
  const ENDPOINT = "https://api.example.com/api/connection";
  const root = document.getElementById("connection-check");
  if (!root) return;

  // Edit these to match what your rules actually do to each state.
  const HEADLINES = {
    clear: "Nothing unusual about your connection.",
    notice: "A few things about your connection are worth knowing.",
    attention: "Some actions from this connection will ask for extra verification.",
    unknown: "We couldn't check your connection right now.",
  };

  const button = document.createElement("button");
  button.type = "button";
  button.textContent = "Check my connection";
  const output = document.createElement("div");
  output.setAttribute("aria-live", "polite");
  root.append(button, output);

  button.addEventListener("click", async () => {
    button.disabled = true;
    output.replaceChildren(line("Checking..."));
    try {
      const res = await fetch(ENDPOINT, { credentials: "omit", signal: AbortSignal.timeout(4000) });
      render(await res.json().catch(() => ({ summary: "unknown" })));
    } catch (err) {
      // Network error or timeout: say so plainly, never guess a verdict.
      console.warn("connection check failed:", err?.message);
      render({ summary: "unknown" });
    } finally {
      button.disabled = false;
    }
  });

  function render(data) {
    const items = [line(HEADLINES[data?.summary] ?? HEADLINES.unknown)];
    if (data?.ip) items.push(line(`We see your connection as ${data.ip}.`));
    for (const f of data?.findings ?? []) items.push(line(f?.text, f?.tone));
    if (data?.message) items.push(line(data.message));
    output.replaceChildren(...items);
  }

  // textContent, never innerHTML: provider names come from third-party data.
  function line(text, tone = "info") {
    const p = document.createElement("p");
    p.className = `cc-${tone === "warn" ? "warn" : "info"}`;
    p.textContent = String(text ?? "");
    return p;
  }
})();
Enter fullscreen mode Exit fullscreen mode

On failure it says it couldn't check, and nothing else. The widget is informational, so a broken lookup should never look like a verdict.

Dropping it into Atlassian Statuspage

Statuspage lets you inject custom header and footer HTML, which is all this needs. The catch, per Atlassian's custom HTML docs: custom HTML only renders on a custom domain like status.example.com. Custom HTML also isn't on every plan, so check yours before you promise this to support. Paste this into the custom footer:

<div id="connection-check"></div>
<script src="https://api.example.com/widget.js" defer></script>
Enter fullscreen mode Exit fullscreen mode

Self-hosted status pages (Upptime, Cachet, a /status route in your own app) take the same two lines. The script loads from your API's domain, and only the fetch needs CORS, which the server already scopes to the status page origin.

Optional: a live check for "I'm not on a VPN, I swear"

Everything so far is IP history. The 145.223.7.7 copy admits it: the address is listed as a VPN exit, and that may say nothing about whoever is using it today. For that complaint, the useful question is what this session is doing right now.

IPGeolocation's Real-Time Proxy & VPN Detection is a browser-only script that runs live tests against the connection itself. There's no API key; it's authorized by Request Origin, so register the status page's origin first. A verdict takes 1 to 5 seconds and costs 3 credits, or 5 with the security data attached, which the server already has, so skip it. It's a paid feature. Because it runs in the visitor's browser, your server's limits don't apply to it. It gets its own button and never runs on load, even though the docs suggest starting early.

// public/live-check.js: optional second button inside the same widget.
(() => {
  const SCRIPT = "https://static.ipgeolocation.io/web-assets/static/security/session-analysis.js";
  const root = document.getElementById("connection-check");
  if (!root) return;

  const button = document.createElement("button");
  button.type = "button";
  button.textContent = "Run a live test (a few seconds)";
  const out = document.createElement("p");
  out.setAttribute("aria-live", "polite");
  root.append(button, out);

  const loadScript = () => new Promise((resolve, reject) => {
    if (window.Analysis) return resolve();
    const el = document.createElement("script");
    el.src = SCRIPT;
    el.onload = () => resolve();
    el.onerror = () => reject(new Error("live-check script failed to load"));
    document.head.append(el);
  });

  button.addEventListener("click", async () => {
    button.disabled = true;
    out.textContent = "Testing your connection...";
    try {
      await loadScript();
      // No key here: the request is authorized by the page's registered origin.
      const result = await window.Analysis.startMonitoring().get();
      out.textContent = describe(result?.live_vpn_proxy_detection);
      button.remove(); // one live test per page view; each run costs credits
      // visitor_actual_location is ignored on purpose. Never print the real IP behind a VPN.
    } catch (err) {
      console.warn("live check failed:", err?.message);
      out.textContent = "The live test couldn't run in this browser.";
      button.disabled = false;
    }
  });

  function describe(live) {
    if (!live) return "The live test didn't return a result.";
    // Below 30 the docs call the signal very weak, so claim nothing.
    if ((live.confidence_score ?? 0) < 30) return "The live test was inconclusive.";
    if (!live.is_anonymous) return "Right now, your connection looks direct: no VPN or proxy detected.";
    return (live.proxy_score ?? 0) >= (live.vpn_score ?? 0)
      ? "Right now, your traffic looks like it's going through a proxy."
      : "Right now, your traffic looks like it's going through a VPN.";
  }
})();
Enter fullscreen mode Exit fullscreen mode

Put this next to the server result and you get the sentence support actually needs: listed as a VPN exit, but direct right now. That usually means the address changed hands, and it's the screenshot that closes the ticket.

Pitfall: treat the live verdict as display-only. It runs in the visitor's browser, so anyone can edit what it reports. Never post it back to your server as a trust signal.

A few extra notes

Mobile carriers put many subscribers behind one CGNAT address, so abuse history on a phone connection is rarely about the phone in your hand. That's why the copy says "this IP" everywhere and "you" almost nowhere.

The cache is keyed by exact IP, so a visitor who turns their VPN off gets a fresh lookup the moment their address changes. IPv6 privacy addresses rotate on their own, which can mean more cache misses than you'd expect. Keep security results keyed to the exact address rather than broadening one verdict across a /64; if the budget starts tripping, increase the TTL, tighten rate limits, or raise the budget instead.

Testing on localhost hits the 423 path, because 127.0.0.1 and LAN addresses are bogons. Expose the server through a tunnel, or hardcode a test IP in lookupSecurity and delete it before you commit.

You're showing someone data about their own IP, which is the friendly direction. Don't undo that by logging the findings next to user IDs. The lookup itself is the only record you need.

Ship the server endpoint first, with the score already stripped, and watch the X-Credits-Charged header for a week before adding the live button. If the number surprises you, raise the cache TTL before you touch the budget. And link the widget from your block page, because the people who need it most won't think to open your status page.

Top comments (0)