IPASIS - IP Reputation and Risk Intelligence API
Blog/Integration Guide

How to Add Bot Detection to Express.js with Middleware

March 29, 202616 min read
app.use()
Express.js + IPASIS

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:

middleware/bot-detection.js
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:

app.js
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:

middleware/bot-detection-cached.js
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:

middleware/tiered-protection.js
// 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:

middleware/circuit-breaker.js
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:

middleware/redis-cache.js
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:

middleware/smart-rate-limit.js
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:

middleware/security-logger.js
// 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:

app.js — Complete Example
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=production

Testing 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:

ScenarioAdded LatencyAPI Calls/min
No cache (every request)30-50ms60,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.1ms0

With caching, the middleware adds effectively zero latency for most requests while maintaining real-time protection against new threat IPs.

Next Steps

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.