DEV Community

Cover image for Block Bots Before They Hit Your API; IP Reputation in Node.js
Sameer Sheikh for WhoisFreaks

Posted on

Block Bots Before They Hit Your API; IP Reputation in Node.js

Most API abuse gets caught after it lands. A rate limiter trips, an alert fires, someone checks the logs the next morning and finds a Tor exit node hammered /login four thousand times overnight. Reactive. I wanted to catch the traffic at the door instead, before it touches a single route handler.

The idea is simple. Every request arrives with an IP. That IP already has a reputation — it's a residential broadband line, or it's a datacenter, or it's a known VPN, or it's an address that's been throwing credential-stuffing attempts at half the internet for a week. If you know which one you're looking at before your handler runs, you can decide what to do with it. Block the obvious attackers. Challenge the suspicious ones. Wave the clean ones through without friction.

That last part matters. Most tutorials I've read on this treat it as a binary: VPN detected, block. But a corporate user on a datacenter IP and a bot running out of a bulletproof host are not the same threat, and slamming the door on both is how you lose real customers. So this middleware grades on a curve.

Here's the whole path a request takes before it ever reaches your handler:

Flowchart of a request passing through the middleware: check for private or localhost IP, then cache, then call the WhoisFreaks API, fail open on error, then decide an action from the threat score

The piece everyone skips

Here's the thing that took me longer than it should have: getting the real client IP.

Behind a load balancer, a CDN, or basically any modern deploy, req.socket.remoteAddress is the proxy in front of you, not the visitor. The real address sits in the X-Forwarded-For header. Express won't read it unless you tell it to trust the proxy, and if you trust it wrong, you've handed attackers a header they can spoof to look like 127.0.0.1. More on that later. For now, know that this one line does a lot of quiet work:

app.set("trust proxy", true);
Enter fullscreen mode Exit fullscreen mode

Setup

Node 18+ (we're using the built-in fetch, no HTTP client dependency), Express, and a free WhoisFreaks key.

git clone https://github.com/WhoisFreaks/ip-reputation-middleware
cd ip-reputation-middleware
npm install
cp .env.example .env   # paste your API key
Enter fullscreen mode Exit fullscreen mode

The key is free — 500 credits, no card. Each IP lookup costs one credit. That's enough to build and test the whole thing with credits to spare.

Step 1: One lookup

Start with the smallest possible thing that works. Given an IP, ask what its reputation is.

The endpoint is a plain GET. Auth is a query parameter called apiKey, and you pass the address in ip. The full request and response schema is documented in the WhoisFreaks IP Reputation API reference. The response comes back as an array with one object per IP, and the part we care about lives under security:

const BASE_URL = "https://api.whoisfreaks.com/v1.0/security";

export async function lookupIp(ip, { apiKey = process.env.WHOISFREAKS_API_KEY } = {}) {
  const url = `${BASE_URL}?apiKey=${apiKey}&ip=${encodeURIComponent(ip)}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`whoisfreaks_http_${res.status}`);
  const body = await res.json();
  return Array.isArray(body) ? body[0] : body;
}
Enter fullscreen mode Exit fullscreen mode

The security object is richer than the single boolean most APIs hand you. You get threat_score (0–100), plus is_vpn, is_proxy, is_residential_proxy, is_tor, is_bot, is_known_attacker, is_spam, and is_cloud_provider, along with confidence scores and provider names for the VPN and proxy hits. That granularity is what makes graded responses possible instead of a flat block.

Step 2: Decide, don't just detect

This is the core of the whole thing, and it's ten lines. The composite threat_score does the heavy lifting. I mapped it to four actions using the score bands WhoisFreaks documents:

export function decide(security) {
  const score = security?.threat_score ?? 0;
  if (score >= 76) return "block";      // known-bad: reject
  if (score >= 51) return "step_up";    // high: require extra auth
  if (score >= 26) return "challenge";  // elevated: CAPTCHA / rate limit
  return "allow";                       // low: let it through
}
Enter fullscreen mode Exit fullscreen mode

Why four tiers and not two? Because a score of 40 usually means "datacenter or commercial VPN" — plenty of legitimate people browse from those. A score of 90 means the address is actively bad. You want the first group to see a CAPTCHA at worst and the second group to see nothing but a 403. One threshold can't express that.

Threat score scale from 0 to 100 split into four bands: 0 to 25 allow, 26 to 50 challenge, 51 to 75 step-up, 76 to 100 block, each with its response action

Step 3: Wrap it in middleware

Now make it an Express middleware factory so each route or app can plug in its own responses per tier.

export function ipReputation(options = {}) {
  const { failOpen = true, onBlock, onChallenge, onStepUp } = options;

  return async function (req, res, next) {
    const ip = clientIp(req);
    if (isPrivate(ip)) return next();   // skip localhost / internal traffic

    let record;
    try {
      record = await lookupIp(ip);
    } catch (err) {
      if (failOpen) return next();      // API down? don't take the app down with it
      return res.status(503).json({ error: "reputation_unavailable" });
    }

    const security = record?.security ?? {};
    req.ipReputation = security;

    switch (decide(security)) {
      case "block":     return onBlock ? onBlock(req, res, next) : res.status(403).json({ error: "forbidden" });
      case "step_up":   return onStepUp ? onStepUp(req, res, next) : next();
      case "challenge": return onChallenge ? onChallenge(req, res, next) : next();
      default:          return next();
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Two decisions in there are worth calling out.

Fail open. If the reputation API times out or returns a 500, the request goes through. I went back and forth on this. But think about what fail-closed means: your threat provider has one bad minute and your login page returns 503 to every real customer on earth. That's a self-inflicted outage in exchange for maybe blocking one risky request. Not worth it. Fail open, log the miss, move on.

Skip private IPs. Health checks, internal service calls, and your own localhost testing shouldn't burn credits or get scored. The isPrivate check short-circuits them.

Step 4: Get the client IP right

The helper the whole thing leans on:

export function clientIp(req) {
  return (req.ip || "").replace(/^::ffff:/, "");  // strip IPv4-mapped IPv6 prefix
}
Enter fullscreen mode Exit fullscreen mode

That ::ffff: strip bit me the first time. Express hands you IPv4 addresses wrapped as IPv6 (::ffff:8.8.8.8), and if you pass that straight to the API you'll get nonsense back. Small detail, real bug.

On trust proxy: setting it to true is fine when every request genuinely passes through a proxy you control. If your app is also reachable directly, a client can send their own X-Forwarded-For and impersonate any IP they like, including a clean one. In that case set it to the exact number of proxy hops instead. Know your deploy.

Step 5: Cache, because credits and latency

Adding a network call to every request is a great way to make every request slower and drain 500 credits by lunch. The reputation data refreshes about once a day, so caching a result for a few hours costs nothing in accuracy:

const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
const cache = new Map();
// check cache.get(ip) before fetch; cache.set(ip, { record, expires }) after
Enter fullscreen mode Exit fullscreen mode

An in-memory Map is fine for one process. Swap in Redis the moment you run more than one. The free tier caps you at 10 requests per minute, so on a real site the cache isn't an optimization — it's the thing that keeps you under the limit at all.

Step 6: Test the part that matters

You don't need to hit the live API to test the logic that decides someone's fate. The tiering is pure and deterministic, so test the boundaries directly:

test("elevated scores get challenged", () => {
  assert.equal(decide({ threat_score: 26 }), "challenge");
  assert.equal(decide({ threat_score: 50 }), "challenge");
});

test("malicious scores are blocked", () => {
  assert.equal(decide({ threat_score: 76 }), "block");
  assert.equal(decide({ threat_score: 100 }), "block");
});
Enter fullscreen mode Exit fullscreen mode

Off-by-one errors here are the whole ballgame. Get >= 76 wrong and you either block clean users at 75 or let attackers through at 76.

Real results

Running the suite:

▶ node --test

ok 1 - low scores are allowed
ok 2 - elevated scores get challenged
ok 3 - high scores require step-up auth
ok 4 - malicious scores are blocked
ok 5 - missing score defaults to allow (fail open)

# tests 5
# pass 5
# fail 0
Enter fullscreen mode Exit fullscreen mode

Then I pointed the batch script at 20 IPs pulled from a day of access logs — a mix of hosting ranges, commercial VPN exit points, and a couple of addresses I already suspected. The script only prints what's worth acting on, so the clean ones drop out:

$ node enrich-logs.js sample-ips.txt
scoring 20 IPs...

45.83.220.14     score= 50  challenge  -
185.159.157.22   score= 80  block      is_known_attacker
193.176.86.103   score= 50  challenge  -
89.187.178.211   score= 50  challenge  -
138.199.21.90    score= 50  challenge  -
37.120.145.60    score= 50  challenge  -
143.244.47.201   score= 50  challenge  -
185.220.101.34   score= 85  block      is_tor,is_known_attacker
195.181.170.12   score= 50  challenge  -
198.54.117.200   score= 35  challenge  is_known_attacker
5.253.207.88     score= 50  challenge  -
146.70.124.19    score= 50  challenge  -
217.138.222.101  score= 50  challenge  -
84.17.35.176     score= 50  challenge  -
156.146.63.128   score= 50  challenge  -
Enter fullscreen mode Exit fullscreen mode

Twenty in, fifteen surfaced. Two hit the block tier outright. The other five scored low enough to pass and never printed.

What I noticed

A few things stood out once I had real scores in front of me:

  • The two blocks were the clear-cut ones. 185.220.101.34 came back is_tor and is_known_attacker at 85 — that's a public Tor exit node, and it earned the 403. 185.159.157.22 hit 80 on is_known_attacker alone. No argument with either.
  • Most of the list clustered at exactly 50 with no individual flags set. These were hosting and commercial-VPN ranges, and the API parked them in the middle: not clean, not confirmed-malicious. That's the tiering earning its keep. A binary "is this a VPN" check would either wave all of them through or block real users behind a VPN. Sitting them in the challenge tier — CAPTCHA, not a door slam — is the right call for that gray zone.
  • The score and the flags don't always agree. 198.54.117.200 carried is_known_attacker but only scored 35. If I'd keyed the block decision off the flag alone, I'd have blocked it; off the score alone, I'd have under-reacted. Reading both is why it landed in challenge instead of a wrong call in either direction.
  • The flat 50s are worth a second look before you trust them in production. When a lot of distinct IPs land on the same round number with empty flags, that can mean the dataset has coarse coverage for those ranges rather than a precise read on each one. I'd verify a couple against a raw API response before wiring the thresholds to anything expensive. ## Going further

A few directions from here:

  • Push blocks down to the edge. Once enrich-logs.js finds repeat offenders, drop them in an nginx deny list or a WAF rule so they never reach Node at all.
  • Persist the cache in Redis and share it across your whole fleet.
  • Feed the asn and connection_type fields into your own scoring if you want app-specific rules on top of the score.
  • If you'd rather not run and maintain the lookup layer yourself, the WhoisFreaks IP Reputation API provides the scoring and the VPN/proxy/Tor/bot flags as a managed service, so you're just reading a field. ## Full source code

Everything above, wired together and runnable, is on GitHub: github.com/WhoisFreaks/ip-reputation-middleware. Clone it, drop in a key, and npm start.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

I particularly appreciated the attention to detail in handling the X-Forwarded-For header to get the real client IP, as this is often a overlooked aspect in many tutorials. The use of app.set("trust proxy", true) is a simple yet effective solution to this problem. I've found that implementing a robust IP reputation system like this can significantly reduce the noise in our logs and prevent unnecessary alerts. One potential improvement could be to integrate this middleware with a caching layer to minimize the number of API calls to WhoisFreaks, especially if you're dealing with a high volume of requests. Have you considered exploring any caching strategies to optimize the performance of this middleware?