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:
- rpm — How fast are requests coming in?
- error_rate — What percentage of requests return errors?
- entropy — How many different endpoints are being hit?
- cv_gap — How regular is the timing between requests?
- 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
- 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
}
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)
}
})
Install
npm install shadowshield
Usage
import { shadowShield } from "shadowshield"
app.use(shadowShield({
redisUrl: "redis://127.0.0.1:6379",
threshold: 0.5,
blockTTL: 3600,
}))
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
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)