DEV Community

Shreesh Sanyal
Shreesh Sanyal

Posted on

I built an Express.js middleware that detects bots using behavioral scoring — and published it to npm

Ever watched your “protected” Express API get farmed by bots anyway?

You add IP rate limiting… they rotate IPs.

You add CAPTCHAs… your users hate you.

You add a WAF… your finance team hates you.

So I built ShadowShield — a free, open‑source behavioral security middleware for Express that detects bots by analyzing how they make requests, not just how many.


How it works

Most rate limiters ask one question:

“How many requests did this IP make?”

ShadowShield asks five:

  1. rpm — How fast are requests coming in?
  2. error_rate — What percentage of requests return errors?
  3. entropy — How many different endpoints are being hit?
  4. cv_gap — How regular is the timing between requests?
  5. volume — How much data is being transferred?

Each feature is normalized and weighted into a final risk score.

If the score exceeds 0.5, the IP is blocked.


The cv_gap feature

This is the most interesting signal.

Bots are unnaturally consistent. A human using your API will have random gaps between requests — 800 ms, 2.3 s, 400 ms.

A bot running in a tight loop will look like — 300 ms, 301 ms, 299 ms.

cv_gap measures the coefficient of variation of those timing gaps.

Low cv_gap = unnaturally regular = likely bot.

const gaps = sorted.slice(1).map((t, i) => t - sorted[i])
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length
const std  = Math.sqrt(
  gaps.reduce((a, b) => a + (b - mean) ** 2, 0) / gaps.length
)
const cvGap = mean > 0 ? std / mean : 0
Enter fullscreen mode Exit fullscreen mode
  • Normal users usually have cvGap > 1.0
  • Simple bots often have cvGap < 0.1

Impossible Travel Detection

This one is my favourite feature.

ShadowShield stores the IP address bound to each session.

If a new request arrives with the same session ID but a different IP, both IPs get blocked immediately.

This catches:

  • Session hijacking
  • IP‑rotating bots that reuse session cookies
const lastIP = await redis.get(`session:${sessionId}:ip`)
if (lastIP && lastIP !== ip) {
  await redis.set(`block:${ip}`, '1', 'EX', blockTTL)
  await redis.set(`block:${lastIP}`, '1', 'EX', blockTTL)
  res.status(429).json({ error: "Suspicious activity detected" })
  return
}
Enter fullscreen mode Exit fullscreen mode

Zero latency impact

This was a hard requirement for me.

All scoring happens after the response is sent using res.on('finish').

The user never waits for risk calculation.

next() // ← response sent immediately

res.on('finish', async () => {
  // scoring happens here — after user already got response
  await writeIPData(data, redis)
  const risk = await IPRiskScore(ip, redis)
  if (risk >= threshold) {
    await redis.set(`block:${ip}`, '1', 'EX', blockTTL)
  }
})
Enter fullscreen mode Exit fullscreen mode

Install

npm install shadowshield
Enter fullscreen mode Exit fullscreen mode

Usage

import { shadowShield } from "shadowshield"

app.use(shadowShield({
  redisUrl:  "redis://127.0.0.1:6379",
  threshold: 0.5,
  blockTTL:  3600,
}))
Enter fullscreen mode Exit fullscreen mode

That’s it. One middleware, a few options.


What the logs look like

When running, ShadowShield prints risk scores in real time:

IP: 192.168.1.1 | ip_risk: 0.12 | session_risk: 0.10 | final: 0.11
IP: 192.168.1.2 | ip_risk: 0.51 | session_risk: 0.66 | final: 0.58
BLOCKED: 192.168.1.2 | ip: 0.51 | session: 0.66 | final: 0.58
IMPOSSIBLE TRAVEL: abc123 | 192.168.1.1 → 192.168.1.2
Enter fullscreen mode Exit fullscreen mode

Tech stack

  • Node.js + TypeScript
  • Express.js
  • Redis (ioredis)
  • Published on npm

Links

🔗 https://github.com/SHREESH2004/Shadowsheild
📦 https://www.npmjs.com/package/shadowshield

Top comments (0)