How to Add Bot Detection
to Express.js with Middleware
Express.js middleware is the natural place to add bot detection. Every request flows through your middleware stack before reaching route handlers — making it the perfect chokepoint to screen traffic, block bots, and enrich requests with risk data.
This guide walks you through building a production-ready bot detection middleware for Express.js using IPASIS IP intelligence. We'll start simple and progressively add caching, route-level protection tiers, rate limiting, and monitoring.
📋 What You'll Build
- Express middleware that checks every request against IPASIS IP intelligence
- In-memory LRU cache to minimize API calls and latency
- Route-level protection tiers (strict for auth, moderate for content, relaxed for public)
- Graceful degradation with circuit breaker pattern
- Request enrichment so route handlers can access risk data
- Structured logging for security monitoring
Step 1: Basic Bot Detection Middleware
Let's start with the simplest possible middleware — check the request IP against IPASIS and block high-risk traffic:
const IPASIS_API_KEY = process.env.IPASIS_API_KEY;
const IPASIS_BASE_URL = 'https://api.ipasis.com/v1';
// Extract real client IP (handles proxies, load balancers)
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return req.headers['x-real-ip'] || req.socket.remoteAddress;
}
// Basic bot detection middleware
async function botDetection(req, res, next) {
const ip = getClientIp(req);
// Skip private/localhost IPs
if (ip === '127.0.0.1' || ip === '::1' || ip?.startsWith('192.168.')) {
return next();
}
try {
const response = await fetch(
`${IPASIS_BASE_URL}/check?ip=${ip}`,
{
headers: { 'Authorization': `Bearer ${IPASIS_API_KEY}` },
signal: AbortSignal.timeout(3000), // 3s timeout
}
);
const data = await response.json();
// Attach risk data to request for downstream use
req.ipRisk = data;
// Block high-risk IPs
if (data.risk_score > 0.7) {
console.warn(`[BOT] Blocked ${ip} — score: ${data.risk_score}`);
return res.status(403).json({
error: 'Request blocked',
code: 'IP_RISK_HIGH',
});
}
next();
} catch (err) {
// Fail open — don't block users if API is unreachable
console.error(`[BOT] IPASIS check failed for ${ip}:`, err.message);
next();
}
}
module.exports = { botDetection, getClientIp };Usage in your Express app:
const express = require('express');
const { botDetection } = require('./middleware/bot-detection');
const app = express();
// Apply globally — every request gets checked
app.use(botDetection);
app.post('/api/signup', (req, res) => {
// req.ipRisk is available here with full risk data
if (req.ipRisk?.is_proxy) {
// Flag for manual review but don't block
req.body._flagged = true;
}
// ... signup logic
});
app.listen(3000);This works, but it makes an API call for every single request. Let's fix that.
Step 2: Add In-Memory Caching
IP risk scores don't change every second. A 5-minute cache dramatically reduces API calls while keeping protection current:
const IPASIS_API_KEY = process.env.IPASIS_API_KEY;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_SIZE = 10_000; // Max IPs to cache
// Simple LRU-ish cache (Map preserves insertion order)
const ipCache = new Map();
function getCachedResult(ip) {
const entry = ipCache.get(ip);
if (!entry) return null;
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
ipCache.delete(ip);
return null;
}
// Move to end (most recently used)
ipCache.delete(ip);
ipCache.set(ip, entry);
return entry.data;
}
function setCachedResult(ip, data) {
// Evict oldest if at capacity
if (ipCache.size >= MAX_CACHE_SIZE) {
const oldest = ipCache.keys().next().value;
ipCache.delete(oldest);
}
ipCache.set(ip, { data, timestamp: Date.now() });
}
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
return req.headers['x-real-ip'] || req.socket.remoteAddress;
}
async function botDetection(req, res, next) {
const ip = getClientIp(req);
if (ip === '127.0.0.1' || ip === '::1' || ip?.startsWith('192.168.')) {
return next();
}
// Check cache first
const cached = getCachedResult(ip);
if (cached) {
req.ipRisk = cached;
if (cached.risk_score > 0.7) {
return res.status(403).json({ error: 'Request blocked' });
}
return next();
}
try {
const response = await fetch(
`https://api.ipasis.com/v1/check?ip=${ip}`,
{
headers: { 'Authorization': `Bearer ${IPASIS_API_KEY}` },
signal: AbortSignal.timeout(3000),
}
);
const data = await response.json();
setCachedResult(ip, data);
req.ipRisk = data;
if (data.risk_score > 0.7) {
return res.status(403).json({ error: 'Request blocked' });
}
next();
} catch (err) {
console.error(`[BOT] Check failed for ${ip}:`, err.message);
next(); // Fail open
}
}
module.exports = { botDetection, getClientIp };⚡ Performance Impact
With a 5-minute cache and typical traffic patterns, cache hit rates reach 85-95%. A returning visitor making 20 requests/session triggers only 1 API call. For high-traffic apps, consider Redis caching (shown in Step 5) to share state across cluster workers.
Step 3: Route-Level Protection Tiers
Not every route needs the same protection level. A public blog page should have relaxed rules, while signup and payment endpoints need strict filtering:
// Protection tiers with different thresholds
const TIERS = {
strict: {
blockThreshold: 0.5, // Block above 50% risk
blockProxy: true, // Block all proxies
blockVPN: true, // Block VPN users
blockDatacenter: true, // Block datacenter IPs
},
moderate: {
blockThreshold: 0.7, // Block above 70% risk
blockProxy: true, // Block proxies
blockVPN: false, // Allow VPN users
blockDatacenter: true, // Block datacenter IPs
},
relaxed: {
blockThreshold: 0.85, // Only block very high risk
blockProxy: false, // Allow proxies
blockVPN: false, // Allow VPN users
blockDatacenter: false, // Allow datacenter IPs
},
};
// Middleware factory — returns middleware for a specific tier
function protect(tierName = 'moderate') {
const tier = TIERS[tierName];
if (!tier) throw new Error(`Unknown protection tier: ${tierName}`);
return async (req, res, next) => {
const ip = getClientIp(req);
const risk = await checkIp(ip); // Uses cached check from Step 2
if (!risk) return next(); // Fail open
req.ipRisk = risk;
// Check tier-specific rules
if (risk.risk_score > tier.blockThreshold) {
return res.status(403).json({
error: 'Access denied',
code: 'RISK_THRESHOLD',
});
}
if (tier.blockProxy && risk.is_proxy) {
return res.status(403).json({
error: 'Proxy traffic not allowed',
code: 'PROXY_BLOCKED',
});
}
if (tier.blockVPN && risk.is_vpn) {
return res.status(403).json({
error: 'VPN traffic not allowed on this endpoint',
code: 'VPN_BLOCKED',
});
}
if (tier.blockDatacenter && risk.is_datacenter) {
return res.status(403).json({
error: 'Datacenter traffic not allowed',
code: 'DATACENTER_BLOCKED',
});
}
next();
};
}
// Usage in routes:
// app.post('/api/signup', protect('strict'), signupHandler);
// app.post('/api/login', protect('strict'), loginHandler);
// app.post('/api/checkout', protect('strict'), checkoutHandler);
// app.get('/api/products', protect('moderate'), productsHandler);
// app.get('/api/blog/:slug', protect('relaxed'), blogHandler);
module.exports = { protect };Step 4: Circuit Breaker for Resilience
If the IPASIS API goes down (or your network has issues), you don't want every request to hang for 3 seconds waiting for a timeout. A circuit breaker pattern handles this gracefully:
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeMs = options.resetTimeMs || 30_000; // 30s
this.failures = 0;
this.state = 'closed'; // closed = normal, open = bypassing
this.lastFailure = 0;
}
async execute(fn) {
// If circuit is open, check if we should try again
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.resetTimeMs) {
this.state = 'half-open'; // Try one request
} else {
return null; // Skip API call entirely
}
}
try {
const result = await fn();
// Success — reset failures
this.failures = 0;
this.state = 'closed';
return result;
} catch (err) {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.failureThreshold) {
this.state = 'open';
console.warn(
`[CIRCUIT BREAKER] Open — ${this.failures} consecutive failures. ` +
`Bypassing for ${this.resetTimeMs / 1000}s`
);
}
return null;
}
}
}
// Singleton circuit breaker for IPASIS API
const ipasisBreaker = new CircuitBreaker({
failureThreshold: 5,
resetTimeMs: 30_000,
});
async function checkIpWithBreaker(ip) {
return ipasisBreaker.execute(async () => {
const response = await fetch(
`https://api.ipasis.com/v1/check?ip=${ip}`,
{
headers: {
'Authorization': `Bearer ${process.env.IPASIS_API_KEY}`,
},
signal: AbortSignal.timeout(2000),
}
);
if (!response.ok) {
throw new Error(`IPASIS API returned ${response.status}`);
}
return response.json();
});
}
module.exports = { CircuitBreaker, checkIpWithBreaker };💡 Why Fail Open?
When the circuit breaker is open, the middleware lets all traffic through. This is intentional — a brief window of unprotected traffic is better than blocking all your legitimate users. For high-security environments (banking, healthcare), you might prefer fail closed — but understand the availability trade-off.
Step 5: Redis Cache for Clustered Apps
If you run Express behind PM2, a cluster, or Kubernetes with multiple replicas, an in-memory cache won't share state between workers. Redis solves this:
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
const CACHE_TTL = 300; // 5 minutes in seconds
const KEY_PREFIX = 'ipasis:risk:';
async function getCachedRisk(ip) {
const cached = await redis.get(KEY_PREFIX + ip);
if (cached) {
return JSON.parse(cached);
}
return null;
}
async function setCachedRisk(ip, data) {
await redis.setex(
KEY_PREFIX + ip,
CACHE_TTL,
JSON.stringify(data)
);
}
// Production middleware combining cache + circuit breaker
async function botDetection(req, res, next) {
const ip = getClientIp(req);
if (!ip || ip === '127.0.0.1' || ip === '::1') {
return next();
}
// 1. Check Redis cache
try {
const cached = await getCachedRisk(ip);
if (cached) {
req.ipRisk = cached;
if (cached.risk_score > 0.7) {
return res.status(403).json({ error: 'Blocked' });
}
return next();
}
} catch (redisErr) {
// Redis down — fall through to API
console.warn('[BOT] Redis cache error:', redisErr.message);
}
// 2. Call IPASIS API (with circuit breaker)
const data = await checkIpWithBreaker(ip);
if (data) {
req.ipRisk = data;
// Cache the result (fire-and-forget)
setCachedRisk(ip, data).catch(() => {});
if (data.risk_score > 0.7) {
return res.status(403).json({ error: 'Blocked' });
}
}
next();
}
module.exports = { botDetection };Step 6: Rate Limiting with IP Risk Awareness
Standard rate limiting treats all IPs equally. But a VPN IP attempting 10 logins/minute is much more suspicious than a clean residential IP doing the same. Combine IPASIS risk data with rate limiting for smarter throttling:
const rateLimit = require('express-rate-limit');
// Dynamic rate limits based on IP risk score
function smartRateLimit(baseOptions = {}) {
return rateLimit({
windowMs: baseOptions.windowMs || 15 * 60 * 1000, // 15 min
standardHeaders: true,
legacyHeaders: false,
// Dynamic max — risky IPs get tighter limits
max: (req) => {
const baseMax = baseOptions.max || 100;
const risk = req.ipRisk;
if (!risk) return baseMax; // No risk data = normal limit
if (risk.is_datacenter || risk.is_proxy) {
return Math.floor(baseMax * 0.2); // 80% reduction
}
if (risk.risk_score > 0.5) {
return Math.floor(baseMax * 0.5); // 50% reduction
}
if (risk.is_vpn) {
return Math.floor(baseMax * 0.7); // 30% reduction
}
return baseMax; // Clean IP = full allowance
},
keyGenerator: (req) => getClientIp(req),
handler: (req, res) => {
const risk = req.ipRisk;
console.warn(
`[RATE LIMIT] ${getClientIp(req)} — ` +
`score: ${risk?.risk_score || 'N/A'}, ` +
`proxy: ${risk?.is_proxy || false}`
);
res.status(429).json({
error: 'Too many requests',
retryAfter: res.getHeader('Retry-After'),
});
},
});
}
// Usage:
// app.use('/api/auth', botDetection, smartRateLimit({ max: 20 }));
// app.use('/api', botDetection, smartRateLimit({ max: 200 }));
module.exports = { smartRateLimit };Step 7: Structured Security Logging
Blocking bots is only half the job. You need visibility into what's being blocked, what's flagged, and what patterns are emerging. Add structured logging to your middleware:
// Structured security event logging
function logSecurityEvent(req, action, reason, risk) {
const event = {
timestamp: new Date().toISOString(),
type: 'security',
action, // 'block', 'flag', 'allow'
reason, // 'high_risk', 'proxy', 'datacenter', etc.
ip: getClientIp(req),
method: req.method,
path: req.path,
userAgent: req.headers['user-agent']?.substring(0, 200),
risk: risk ? {
score: risk.risk_score,
isProxy: risk.is_proxy,
isVPN: risk.is_vpn,
isDatacenter: risk.is_datacenter,
isTor: risk.is_tor,
country: risk.country_code,
asn: risk.asn,
} : null,
};
// Write as JSON line — easy to parse with jq, Datadog, etc.
console.log(JSON.stringify(event));
}
// Wrap your bot detection middleware with logging
function botDetectionWithLogging(req, res, next) {
const originalEnd = res.end;
res.end = function (...args) {
const risk = req.ipRisk;
if (res.statusCode === 403) {
logSecurityEvent(req, 'block', 'bot_detected', risk);
} else if (risk?.risk_score > 0.4) {
logSecurityEvent(req, 'flag', 'elevated_risk', risk);
}
originalEnd.apply(res, args);
};
botDetection(req, res, next);
}
module.exports = { logSecurityEvent, botDetectionWithLogging };Step 8: Putting It All Together
Here's a complete Express.js application with all the patterns combined — production-ready bot detection:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
// Import our middleware modules
const { botDetection } = require('./middleware/bot-detection-cached');
const { protect } = require('./middleware/tiered-protection');
const { smartRateLimit } = require('./middleware/smart-rate-limit');
const app = express();
// Security basics
app.use(helmet());
app.use(cors());
app.use(express.json());
// Trust proxy (required for correct IP extraction behind LB)
app.set('trust proxy', 1);
// ── Global bot detection (enriches req.ipRisk) ──────────
app.use(botDetection);
// ── Public routes (relaxed protection) ──────────────────
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/api/blog/:slug', protect('relaxed'), (req, res) => {
// Blog content — allow VPNs, proxies, just block known bots
res.json({ slug: req.params.slug, content: '...' });
});
// ── Authenticated routes (moderate protection) ──────────
app.get('/api/products',
protect('moderate'),
smartRateLimit({ max: 200, windowMs: 15 * 60 * 1000 }),
(req, res) => {
res.json({ products: [] });
}
);
// ── Sensitive routes (strict protection) ────────────────
app.post('/api/signup',
protect('strict'),
smartRateLimit({ max: 10, windowMs: 15 * 60 * 1000 }),
(req, res) => {
const risk = req.ipRisk;
// Additional checks beyond middleware
if (risk?.is_vpn) {
// Don't block, but flag for review
console.log(`[SIGNUP] VPN signup from ${risk.country_code}`);
}
res.json({ success: true, userId: 'new-user-id' });
}
);
app.post('/api/login',
protect('strict'),
smartRateLimit({ max: 20, windowMs: 15 * 60 * 1000 }),
(req, res) => {
// Use risk data to decide on step-up auth
if (req.ipRisk?.risk_score > 0.3) {
return res.json({
requiresMFA: true,
reason: 'elevated_risk',
});
}
res.json({ token: 'jwt-token', requiresMFA: false });
}
);
app.post('/api/checkout',
protect('strict'),
smartRateLimit({ max: 5, windowMs: 5 * 60 * 1000 }),
(req, res) => {
res.json({ orderId: 'order-123' });
}
);
// ── Error handler ───────────────────────────────────────
app.use((err, req, res, _next) => {
console.error('[ERROR]', err.message);
res.status(500).json({ error: 'Internal server error' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log('Bot detection: ACTIVE');
});Deployment Considerations
Behind a Reverse Proxy (Nginx, Cloudflare)
If Express runs behind Nginx, Cloudflare, or a load balancer, the request IP will be the proxy's IP, not the client's. You must configure trust proxy:
// Trust 1 proxy hop (e.g., Nginx)
app.set('trust proxy', 1);
// Trust specific proxy IPs
app.set('trust proxy', '10.0.0.0/8');
// Behind Cloudflare — use CF-Connecting-IP header
function getClientIp(req) {
return req.headers['cf-connecting-ip']
|| req.headers['x-forwarded-for']?.split(',')[0].trim()
|| req.socket.remoteAddress;
}⚠️ Security Warning
Never set trust proxy to true (trust all). An attacker can spoof X-Forwarded-For to bypass your IP checks. Always specify the exact number of hops or the proxy's IP range.
Environment Variables
# .env
IPASIS_API_KEY=your_api_key_here
REDIS_URL=redis://localhost:6379 # Optional, for clustered apps
NODE_ENV=productionTesting Your Middleware
# Test with a known datacenter IP
curl -H "X-Forwarded-For: 185.220.101.1" http://localhost:3000/api/signup
# Test with your real IP
curl http://localhost:3000/api/signup
# Check the risk data attached to the response
curl -s http://localhost:3000/api/health | jq .Common Patterns
Webhook Protection
Protect incoming webhooks from spoofed requests:
app.post('/webhooks/stripe',
protect('moderate'),
async (req, res) => {
// Stripe sends from known IPs, so they'll pass
// But a spoofed webhook from a datacenter bot won't
if (req.ipRisk?.is_datacenter && !isKnownStripeIp(req)) {
return res.status(403).json({ error: 'Unauthorized' });
}
// Process webhook...
res.json({ received: true });
}
);GraphQL Endpoint
// Protect GraphQL with complexity-aware rate limiting
app.use('/graphql',
botDetection,
smartRateLimit({
max: (req) => {
// Introspection queries from datacenter = bot
if (req.body?.query?.includes('__schema') && req.ipRisk?.is_datacenter) {
return 1; // One introspection per window
}
return req.ipRisk?.risk_score > 0.3 ? 50 : 200;
},
}),
graphqlHandler
);Performance Benchmarks
We benchmarked the middleware with a typical Express.js API handling 1,000 requests/second:
| Scenario | Added Latency | API Calls/min |
|---|---|---|
| No cache (every request) | 30-50ms | 60,000 |
| In-memory cache (5 min TTL) | <1ms (hit) / 30-50ms (miss) | ~3,000 |
| Redis cache (5 min TTL) | 1-3ms (hit) / 30-50ms (miss) | ~3,000 |
| Circuit breaker open | <0.1ms | 0 |
With caching, the middleware adds effectively zero latency for most requests while maintaining real-time protection against new threat IPs.
Next Steps
- IPASIS API Documentation — Full API reference with all available fields
- Next.js Middleware Guide — If you're using Next.js instead of Express
- Redis Rate Limiting Patterns — Advanced rate limiting strategies
- Bot Detection Techniques for SaaS — Broader strategies beyond IP intelligence
- Edge-Level Bot Detection — Move protection even closer to the user
Protect Your Express.js API in 5 Minutes
Get your API key and start blocking bots with a single middleware function. No frontend changes required.