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

How to Add Bot Detection to Express.js with Middleware

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

Express.js powers millions of Node.js APIs and web apps. It's also the framework most commonly targeted by bots — credential stuffing attacks against login endpoints, fake signups flooding registration forms, and scrapers hammering API routes.

The good news: Express's middleware architecture makes it trivially easy to add bot detection at any layer. In this guide, you'll build a production-ready bot detection middleware using IPASIS IP intelligence — from a basic implementation to a full-featured system with caching, circuit breaking, tiered protection, and structured logging.

📋

What You'll Build

  • ✅ IP risk scoring middleware with caching
  • ✅ Route-specific protection tiers (login vs browsing)
  • ✅ Circuit breaker for API resilience
  • ✅ Redis-backed cache for clustered deployments
  • ✅ Risk-aware rate limiting
  • ✅ Structured security logging
  • ✅ Complete production-ready Express app

Step 1: Basic Bot Detection Middleware

Start simple. This middleware checks every request's IP against IPASIS and blocks high-risk traffic:

// middleware/botDetection.js
const IPASIS_API_KEY = process.env.IPASIS_API_KEY;
const RISK_THRESHOLD = 70;

async function botDetection(req, res, next) {
  // Extract real client IP (behind proxies)
  const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
    || req.headers['x-real-ip']
    || req.socket.remoteAddress;

  // Skip private/local IPs
  if (isPrivateIP(ip)) return next();

  try {
    const response = await fetch(
      `https://api.ipasis.com/v1/lookup?ip=${ip}`,
      {
        headers: { 'Authorization': `Bearer ${IPASIS_API_KEY}` },
        signal: AbortSignal.timeout(3000) // 3s timeout
      }
    );

    if (!response.ok) return next(); // fail open

    const data = await response.json();

    // Attach risk data to request for downstream use
    req.ipRisk = {
      score: data.risk_score,
      isVPN: data.is_vpn,
      isProxy: data.is_proxy,
      isTor: data.is_tor,
      isDatacenter: data.is_datacenter,
      country: data.country_code,
      isp: data.isp,
    };

    // Block high-risk requests
    if (data.risk_score > RISK_THRESHOLD) {
      return res.status(403).json({
        error: 'Request blocked',
        reason: 'suspicious_ip'
      });
    }

    next();
  } catch (err) {
    // Fail open on errors (don't break the app)
    console.error('Bot detection error:', err.message);
    next();
  }
}

function isPrivateIP(ip) {
  return /^(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|::1|fc|fd)/.test(ip);
}

module.exports = botDetection;
// app.js — apply globally
const express = require('express');
const botDetection = require('./middleware/botDetection');

const app = express();
app.set('trust proxy', true); // if behind nginx/load balancer
app.use(botDetection);

app.post('/api/signup', (req, res) => {
  // req.ipRisk is available here
  console.log('Signup from:', req.ipRisk);
  res.json({ ok: true });
});

This works, but it makes an API call for every single request. Let's fix that.

Step 2: Add In-Memory Caching

IP risk data doesn't change every second. Cache results for 5-10 minutes to dramatically reduce API calls:

// middleware/botDetection.js — with LRU cache
const IPASIS_API_KEY = process.env.IPASIS_API_KEY;

// Simple LRU cache (or use 'lru-cache' package)
class LRUCache {
  constructor(maxSize = 10000, ttlMs = 5 * 60 * 1000) {
    this.cache = new Map();
    this.maxSize = maxSize;
    this.ttlMs = ttlMs;
  }

  get(key) {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() - entry.ts > this.ttlMs) {
      this.cache.delete(key);
      return null;
    }
    // Move to end (most recently used)
    this.cache.delete(key);
    this.cache.set(key, entry);
    return entry.data;
  }

  set(key, data) {
    if (this.cache.size >= this.maxSize) {
      // Delete oldest entry
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
    this.cache.set(key, { data, ts: Date.now() });
  }
}

const ipCache = new LRUCache(10000, 5 * 60 * 1000); // 10K entries, 5 min TTL

async function botDetection(req, res, next) {
  const ip = extractIP(req);
  if (isPrivateIP(ip)) return next();

  // Check cache first
  const cached = ipCache.get(ip);
  if (cached) {
    req.ipRisk = cached;
    if (cached.score > 70) {
      return res.status(403).json({ error: 'Request blocked' });
    }
    return next();
  }

  try {
    const response = await fetch(
      `https://api.ipasis.com/v1/lookup?ip=${ip}`,
      {
        headers: { 'Authorization': `Bearer ${IPASIS_API_KEY}` },
        signal: AbortSignal.timeout(3000)
      }
    );

    if (!response.ok) return next();

    const data = await response.json();
    const riskData = {
      score: data.risk_score,
      isVPN: data.is_vpn,
      isProxy: data.is_proxy,
      isTor: data.is_tor,
      isDatacenter: data.is_datacenter,
      country: data.country_code,
      isp: data.isp,
    };

    // Cache the result
    ipCache.set(ip, riskData);
    req.ipRisk = riskData;

    if (riskData.score > 70) {
      return res.status(403).json({ error: 'Request blocked' });
    }

    next();
  } catch (err) {
    console.error('Bot detection error:', err.message);
    next(); // fail open
  }
}

function extractIP(req) {
  return req.headers['x-forwarded-for']?.split(',')[0]?.trim()
    || req.headers['x-real-ip']
    || req.socket.remoteAddress;
}

function isPrivateIP(ip) {
  return /^(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|::1|fc|fd)/.test(ip);
}

module.exports = botDetection;

With a 10,000-entry LRU cache and 5-minute TTL, a site handling 100K requests/hour from 5,000 unique IPs will make only ~60K API calls/day instead of 2.4M. That's a 97.5% reduction.

Step 3: Route-Specific Protection Tiers

Not every route needs the same protection level. A blog page can tolerate more risk than a login endpoint. Define tiers:

// middleware/tieredProtection.js
const TIERS = {
  critical: {   // Login, signup, payment
    riskThreshold: 40,
    blockVPN: true,
    blockProxy: true,
    blockTor: true,
    blockDatacenter: true,
  },
  high: {       // API endpoints, form submissions
    riskThreshold: 60,
    blockVPN: false,    // some legitimate users use VPNs
    blockProxy: true,
    blockTor: true,
    blockDatacenter: true,
  },
  standard: {   // General browsing, public pages
    riskThreshold: 80,
    blockVPN: false,
    blockProxy: false,
    blockTor: true,
    blockDatacenter: false,
  },
  monitor: {    // Static assets, health checks
    riskThreshold: 100, // effectively never blocks
    blockVPN: false,
    blockProxy: false,
    blockTor: false,
    blockDatacenter: false,
  }
};

function protect(tierName = 'standard') {
  const tier = TIERS[tierName] || TIERS.standard;
  
  return (req, res, next) => {
    const risk = req.ipRisk;
    if (!risk) return next(); // no risk data available
    
    const blocked = 
      risk.score > tier.riskThreshold ||
      (tier.blockVPN && risk.isVPN) ||
      (tier.blockProxy && risk.isProxy) ||
      (tier.blockTor && risk.isTor) ||
      (tier.blockDatacenter && risk.isDatacenter);
    
    if (blocked) {
      return res.status(403).json({
        error: 'Access denied',
        code: 'IP_RISK_BLOCKED'
      });
    }
    
    next();
  };
}

module.exports = { protect, TIERS };
// app.js — apply tiers to routes
const { protect } = require('./middleware/tieredProtection');
const botDetection = require('./middleware/botDetection');

app.use(botDetection); // enrich all requests with risk data

// Critical endpoints — strictest protection
app.post('/api/auth/login', protect('critical'), loginHandler);
app.post('/api/auth/signup', protect('critical'), signupHandler);
app.post('/api/payments', protect('critical'), paymentHandler);

// API endpoints — high protection
app.post('/api/comments', protect('high'), commentHandler);
app.post('/api/contact', protect('high'), contactHandler);

// Public pages — standard protection
app.get('/api/products', protect('standard'), productsHandler);

// Health checks — monitor only
app.get('/health', protect('monitor'), healthHandler);

Step 4: Circuit Breaker for Resilience

If IPASIS goes down (or your network has issues), you don't want every request hanging for 3 seconds waiting for a timeout. A circuit breaker detects failures and temporarily skips the API call:

// middleware/circuitBreaker.js
class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeMs = options.resetTimeMs || 30000; // 30 seconds
    this.failures = 0;
    this.lastFailure = 0;
    this.state = 'closed'; // closed = normal, open = skipping
  }

  canExecute() {
    if (this.state === 'closed') return true;
    
    // Check if reset time has passed
    if (Date.now() - this.lastFailure > this.resetTimeMs) {
      this.state = 'half-open';
      return true; // allow one request to test
    }
    
    return false; // circuit is open, skip
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }

  onFailure() {
    this.failures++;
    this.lastFailure = Date.now();
    if (this.failures >= this.failureThreshold) {
      this.state = 'open';
      console.warn(`Circuit breaker OPEN — ${this.failures} consecutive failures`);
    }
  }

  getState() {
    return this.state;
  }
}

module.exports = CircuitBreaker;
// Updated botDetection middleware with circuit breaker
const CircuitBreaker = require('./circuitBreaker');
const breaker = new CircuitBreaker({ failureThreshold: 5, resetTimeMs: 30000 });

async function botDetection(req, res, next) {
  const ip = extractIP(req);
  if (isPrivateIP(ip)) return next();

  const cached = ipCache.get(ip);
  if (cached) {
    req.ipRisk = cached;
    return next();
  }

  // Circuit breaker check
  if (!breaker.canExecute()) {
    return next(); // skip API call when circuit is open
  }

  try {
    const response = await fetch(
      `https://api.ipasis.com/v1/lookup?ip=${ip}`,
      {
        headers: { 'Authorization': `Bearer ${IPASIS_API_KEY}` },
        signal: AbortSignal.timeout(2000)
      }
    );

    if (!response.ok) throw new Error(`API returned ${response.status}`);

    const data = await response.json();
    breaker.onSuccess(); // reset failures

    const riskData = { /* ... same as before ... */ };
    ipCache.set(ip, riskData);
    req.ipRisk = riskData;
    next();
  } catch (err) {
    breaker.onFailure();
    console.error(`Bot detection failed (circuit: ${breaker.getState()}):`, err.message);
    next(); // fail open
  }
}

Step 5: Redis Cache for Clustered Deployments

If you run multiple Express instances (PM2 cluster, Kubernetes pods, Docker Swarm), an in-memory cache won't share between processes. Use Redis:

// middleware/redisCache.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

const CACHE_PREFIX = 'ipasis:';
const CACHE_TTL = 300; // 5 minutes in seconds

async function getCachedRisk(ip) {
  try {
    const data = await redis.get(CACHE_PREFIX + ip);
    return data ? JSON.parse(data) : null;
  } catch {
    return null; // Redis down, skip cache
  }
}

async function setCachedRisk(ip, riskData) {
  try {
    await redis.setex(
      CACHE_PREFIX + ip,
      CACHE_TTL,
      JSON.stringify(riskData)
    );
  } catch {
    // Redis down, continue without caching
  }
}

module.exports = { getCachedRisk, setCachedRisk };

Swap the in-memory LRU cache for Redis calls in your middleware. The API surface stays the same — just getCachedRisk(ip) and setCachedRisk(ip, data).

Step 6: Risk-Aware Rate Limiting

Standard rate limiting treats all IPs equally. A legitimate user and a bot each get the same 100 requests/minute. With IP risk data, you can apply adaptive rate limits:

// middleware/riskRateLimit.js
const rateLimit = require('express-rate-limit');

function riskAwareRateLimit(options = {}) {
  const {
    windowMs = 60 * 1000,          // 1 minute
    lowRiskMax = 100,               // clean IPs
    mediumRiskMax = 30,             // suspicious IPs
    highRiskMax = 5,                // high risk IPs
    riskThresholdMedium = 30,
    riskThresholdHigh = 60,
  } = options;

  // Create three rate limiters with different limits
  const lowLimiter = rateLimit({ windowMs, max: lowRiskMax, standardHeaders: true });
  const medLimiter = rateLimit({ windowMs, max: mediumRiskMax, standardHeaders: true });
  const highLimiter = rateLimit({ windowMs, max: highRiskMax, standardHeaders: true });

  return (req, res, next) => {
    const score = req.ipRisk?.score ?? 0;

    if (score >= riskThresholdHigh) {
      return highLimiter(req, res, next);
    } else if (score >= riskThresholdMedium) {
      return medLimiter(req, res, next);
    } else {
      return lowLimiter(req, res, next);
    }
  };
}

module.exports = riskAwareRateLimit;
// app.js — apply risk-aware rate limiting
const riskAwareRateLimit = require('./middleware/riskRateLimit');

app.use(botDetection); // must run first to set req.ipRisk

app.use('/api/', riskAwareRateLimit({
  windowMs: 60 * 1000,
  lowRiskMax: 100,    // clean IPs: 100 req/min
  mediumRiskMax: 20,  // suspicious: 20 req/min
  highRiskMax: 3,     // high risk: 3 req/min
}));
💡

Why This Works Better Than Flat Rate Limits

A clean residential IP (risk score 5) gets 100 requests/minute — plenty for normal usage. A datacenter IP running a scraper (risk score 65) gets throttled to 20/minute. A known proxy with abuse history (risk score 85) gets 3/minute. Bots hit walls while humans browse freely.

Step 7: Structured Security Logging

Log every blocked request with enough context for incident investigation:

// middleware/securityLogger.js
function securityLogger(req, res, next) {
  const originalEnd = res.end;
  
  res.end = function (...args) {
    // Log blocked requests (4xx from bot detection)
    if (res.statusCode === 403 && req.ipRisk) {
      const logEntry = {
        timestamp: new Date().toISOString(),
        event: 'ip_blocked',
        ip: extractIP(req),
        method: req.method,
        path: req.path,
        userAgent: req.headers['user-agent']?.substring(0, 200),
        riskScore: req.ipRisk.score,
        flags: {
          vpn: req.ipRisk.isVPN,
          proxy: req.ipRisk.isProxy,
          tor: req.ipRisk.isTor,
          datacenter: req.ipRisk.isDatacenter,
        },
        country: req.ipRisk.country,
        isp: req.ipRisk.isp,
        referer: req.headers['referer'],
      };
      
      // Structured JSON log — easy to parse in ELK/Datadog/CloudWatch
      console.log(JSON.stringify(logEntry));
    }
    
    originalEnd.apply(res, args);
  };
  
  next();
}

module.exports = securityLogger;

This gives you structured JSON logs that you can pipe to any log aggregation service. Each blocked request includes the full risk profile — invaluable for tuning thresholds and understanding attack patterns.

Step 8: Complete Production App

Here's everything assembled into a production-ready Express application:

// server.js — Complete production Express app with bot detection
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

// Our middleware
const botDetection = require('./middleware/botDetection');
const { protect } = require('./middleware/tieredProtection');
const riskAwareRateLimit = require('./middleware/riskRateLimit');
const securityLogger = require('./middleware/securityLogger');

const app = express();

// ---- Global Middleware ----
app.set('trust proxy', true);
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10kb' }));

// Bot detection — enriches req.ipRisk for all requests
app.use(botDetection);

// Security logging — logs all blocked requests
app.use(securityLogger);

// Risk-aware rate limiting on API routes
app.use('/api/', riskAwareRateLimit({
  windowMs: 60 * 1000,
  lowRiskMax: 100,
  mediumRiskMax: 20,
  highRiskMax: 3,
}));

// ---- Routes ----

// Health check (no protection)
app.get('/health', (req, res) => {
  res.json({ status: 'ok', uptime: process.uptime() });
});

// Authentication — CRITICAL protection
app.post('/api/auth/signup', protect('critical'), (req, res) => {
  const { email, password } = req.body;
  // req.ipRisk available for additional checks
  if (req.ipRisk?.score > 30) {
    // Require CAPTCHA for medium-risk signups
    return res.status(428).json({ 
      error: 'verification_required',
      message: 'Please complete verification' 
    });
  }
  res.json({ ok: true, message: 'Account created' });
});

app.post('/api/auth/login', protect('critical'), (req, res) => {
  // Use risk data to decide on MFA
  const requireMFA = req.ipRisk?.score > 20 
    || req.ipRisk?.isVPN 
    || req.ipRisk?.country !== 'expected_country';
  
  res.json({ 
    ok: true, 
    mfaRequired: requireMFA 
  });
});

// API endpoints — HIGH protection
app.post('/api/comments', protect('high'), (req, res) => {
  res.json({ ok: true });
});

app.post('/api/contact', protect('high'), (req, res) => {
  res.json({ ok: true });
});

// Public data — STANDARD protection
app.get('/api/products', protect('standard'), (req, res) => {
  res.json({ products: [] });
});

// Webhooks — verify + protect
app.post('/api/webhooks/stripe', protect('high'), (req, res) => {
  // Stripe sends webhooks from known IPs
  // IPASIS flags datacenter IPs but Stripe's are legitimate
  // Use webhook signature verification as primary check
  res.json({ received: true });
});

// ---- Error handling ----
app.use((err, req, res, _next) => {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: 'Internal server error' });
});

// ---- Start ----
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`Bot detection: active`);
  console.log(`Cache TTL: 5 minutes`);
  console.log(`Circuit breaker: 5 failures → 30s cooldown`);
});

Bonus: Protecting Specific Patterns

Webhook Protection

Webhooks are often targeted by attackers who replay or forge webhook payloads. Use IP risk scoring as an additional layer alongside signature verification:

// Webhook-specific middleware
function webhookProtection(allowedRiskScore = 50) {
  return (req, res, next) => {
    const risk = req.ipRisk;
    
    // Known webhook senders (Stripe, GitHub, etc.) come from 
    // datacenter IPs — don't block datacenter alone
    if (risk && risk.score > allowedRiskScore && !risk.isDatacenter) {
      console.warn('Suspicious webhook attempt:', {
        ip: extractIP(req),
        score: risk.score,
        path: req.path
      });
      return res.status(403).json({ error: 'Forbidden' });
    }
    
    next();
  };
}

app.post('/webhooks/:provider', webhookProtection(60), webhookHandler);

GraphQL Endpoint Protection

// GraphQL endpoints need special attention — single endpoint, 
// multiple operations. Rate limit by operation complexity + IP risk.
app.use('/graphql', botDetection, (req, res, next) => {
  const risk = req.ipRisk;
  
  // Block known malicious IPs from GraphQL entirely
  if (risk?.score > 80) {
    return res.status(403).json({ 
      errors: [{ message: 'Access denied' }] 
    });
  }
  
  // Limit query depth for suspicious IPs
  if (risk?.score > 40) {
    req.maxQueryDepth = 3;  // pass to GraphQL resolver
    req.maxQueryComplexity = 50;
  }
  
  next();
});

File Upload Protection

// Prevent bots from uploading spam/malicious files
app.post('/api/upload', 
  botDetection,
  protect('high'),
  (req, res, next) => {
    // Reduce upload limits for risky IPs
    const maxSize = req.ipRisk?.score > 30 
      ? '1mb'   // risky IP: 1MB max
      : '10mb'; // clean IP: 10MB max
    
    express.raw({ limit: maxSize, type: '*/*' })(req, res, next);
  },
  uploadHandler
);

Reverse Proxy Configuration

If you're running Express behind Nginx or Cloudflare, make sure the real client IP reaches your middleware:

# Nginx — forward real IP
location / {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Host $host;
}

# Express — trust proxy headers
# app.set('trust proxy', 1);        # trust first proxy
# app.set('trust proxy', 'loopback'); # trust localhost proxies
# app.set('trust proxy', true);     # trust all (use with caution)

Performance Benchmarks

ScenarioAdded LatencyNotes
Cache hit (in-memory)<0.1msMap lookup
Cache hit (Redis)0.5-2msNetwork round-trip to Redis
Cache miss (API call)30-80msIPASIS API response time
Circuit breaker open<0.1msSkips API entirely
API timeout2000ms (max)AbortSignal timeout, then fail open

With the LRU cache handling 90%+ of requests, the effective average added latency is under 5ms for most production workloads.

Testing Your Middleware

// test/botDetection.test.js
const request = require('supertest');
const express = require('express');
const botDetection = require('../middleware/botDetection');
const { protect } = require('../middleware/tieredProtection');

describe('Bot Detection Middleware', () => {
  let app;

  beforeEach(() => {
    app = express();
    app.set('trust proxy', true);
    app.use(botDetection);
    app.get('/test', protect('critical'), (req, res) => {
      res.json({ risk: req.ipRisk });
    });
  });

  it('should pass through private IPs', async () => {
    const res = await request(app)
      .get('/test')
      .set('X-Forwarded-For', '192.168.1.1');
    expect(res.status).toBe(200);
  });

  it('should block high-risk IPs on critical routes', async () => {
    // Use a known Tor exit node IP for testing
    const res = await request(app)
      .get('/test')
      .set('X-Forwarded-For', '185.220.101.1');
    // Result depends on actual IPASIS response
    expect([200, 403]).toContain(res.status);
  });

  it('should handle API failures gracefully', async () => {
    // Temporarily break the API URL
    process.env.IPASIS_API_KEY = 'invalid';
    const res = await request(app)
      .get('/test')
      .set('X-Forwarded-For', '8.8.8.8');
    // Should fail open
    expect(res.status).toBe(200);
  });
});

Conclusion

Express.js's middleware architecture makes it one of the easiest frameworks to add bot detection to. With IPASIS, you get real-time IP intelligence that slots directly into the middleware chain — no SDKs to install, no databases to manage, no complex integrations.

Start with the basic middleware (Step 1), add caching (Step 2), and progressively layer in tiered protection, circuit breaking, and risk-aware rate limiting as your needs grow. Each component is independent — use what you need, skip what you don't.

The key principle: fail open, cache aggressively, protect proportionally. Clean users should never notice the bot detection layer. Bots should hit walls at every turn.

Ready to protect your Express.js app?

Start with 1,000 free lookups per day. Get your API key in 30 seconds, add the middleware, and start blocking bots today.

Related Reading