Bot detection without CAPTCHA: device fingerprinting, behavioral signals, and risk scores

CAPTCHA has a conversion rate problem. Every reCAPTCHA v2 checkbox you add to a login form measurably increases abandonment, particularly on mobile. reCAPTCHA v3 solves the UX problem by running invisibly, but introduces a different issue: a score of 0.3 means what, exactly? This post covers how to build layered bot detection that keeps friction minimal for legitimate users while maintaining meaningful protection against automated attacks.

The signal stack

Effective bot detection isn't a single check — it's a combination of signals scored and weighted together. The signals fall into three categories:

  • Network signals — IP reputation, ASN type (residential vs datacenter), VPN/proxy/Tor exit node, geolocation consistency
  • Device/browser signals — fingerprint consistency, headless browser markers, canvas/WebGL fingerprint
  • Behavioral signals — mouse movement patterns, typing dynamics, form fill timing, interaction sequence

JavaScript challenge: the baseline gate

The simplest and highest-ROI check is a JavaScript challenge on the login page. Most credential stuffing bots don't execute JavaScript — they send raw HTTP requests to your auth endpoint. Requiring a JS-computed token proves the client has a browser:

// Server: issue a challenge nonce with each login page load
router.get('/login', (req, res) => {
  const challenge = crypto.randomBytes(16).toString('hex');
  const timestamp = Date.now();
  // Sign: challenge + timestamp
  const sig = createHmac('sha256', process.env.CHALLENGE_SECRET!)
    .update(`${challenge}:${timestamp}`)
    .digest('hex');

  res.render('login', {
    challengeToken: `${challenge}:${timestamp}:${sig}`,
  });
});

// Client-side JS: solve the challenge (simple proof-of-work)
async function solveChallenge(token: string): Promise<string> {
  const parts = token.split(':');
  let nonce = 0;
  // Find a nonce such that SHA-256(token + nonce) starts with '00'
  while (true) {
    const attempt = token + ':' + nonce;
    const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(attempt));
    const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
    if (hex.startsWith('00')) {
      return `${token}:${nonce}`;
    }
    nonce++;
  }
}

// Include solvedToken in the login POST body
// Server verifies the solution and the timestamp (reject if > 10 minutes old)

Headless browser detection

Puppeteer, Playwright, and Selenium are widely used for credential stuffing because they execute JavaScript. However, they leave detectable traces:

// Client-side headless detection signals
function collectBrowserSignals() {
  const signals = {};

  // navigator.webdriver is set to true in automation contexts
  signals.webdriver = navigator.webdriver === true;

  // Chrome headless doesn't have plugins
  signals.pluginCount = navigator.plugins.length;

  // Headless Chrome doesn't have chrome.app
  signals.hasChromeRuntime = !!(window.chrome && window.chrome.runtime);

  // Check for DevTools-related globals injected by automation
  signals.hasPhantom = !!window._phantom;
  signals.hasNightmare = !!window.__nightmare;

  // Timing check: humans take time to move to the submit button
  signals.timeOnPage = Date.now() - window.__pageLoadTime;

  // Screen dimensions: headless browsers often use default 800x600
  signals.screenWidth = screen.width;
  signals.screenHeight = screen.height;

  return signals;
}

// Send signals with the login request
document.getElementById('login-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const signals = collectBrowserSignals();
  document.getElementById('browser-signals').value = btoa(JSON.stringify(signals));
  e.target.submit();
});

Mouse movement entropy

Real users move their mouse in curved, slightly erratic paths before clicking a button. Bots move in straight lines or teleport directly to coordinates. Measuring mouse movement entropy is a surprisingly effective signal:

const mouseData = { events: [], entropy: 0 };

document.addEventListener('mousemove', (e) => {
  mouseData.events.push({ x: e.clientX, y: e.clientY, t: Date.now() });
  // Keep only last 50 events
  if (mouseData.events.length > 50) mouseData.events.shift();
});

function calculateMouseEntropy(): number {
  const events = mouseData.events;
  if (events.length < 5) return 0;

  // Measure direction changes — humans change direction frequently
  let directionChanges = 0;
  for (let i = 2; i < events.length; i++) {
    const dx1 = events[i-1].x - events[i-2].x;
    const dy1 = events[i-1].y - events[i-2].y;
    const dx2 = events[i].x - events[i-1].x;
    const dy2 = events[i].y - events[i-1].y;
    const dotProduct = dx1*dx2 + dy1*dy2;
    if (dotProduct < 0) directionChanges++;
  }

  return directionChanges / (events.length - 2); // ratio of direction changes
}
// Human: entropy > 0.3 typically
// Bot moving in straight line: entropy near 0

Request timing analysis

Server-side: compare the time between page load (challenge issuance) and form submission. Humans take 10–120 seconds to fill in a login form. Bots submit in 50–500ms:

function analyzeRequestTiming(challengeTimestamp: number): {
  suspicious: boolean;
  reason?: string;
} {
  const elapsed = Date.now() - challengeTimestamp;

  if (elapsed < 2000) {
    return { suspicious: true, reason: 'too_fast' }; // < 2 seconds
  }
  if (elapsed > 30 * 60 * 1000) {
    return { suspicious: true, reason: 'stale_challenge' }; // > 30 minutes
  }
  return { suspicious: false };
}

IP reputation scoring

IP reputation data from providers like IPinfo, MaxMind, or AbuseIPDB classifies IPs by type and abuse history. Score inputs:

interface IpReputation {
  isDatacenter: boolean;      // AWS/GCP/Azure/DigitalOcean ranges
  isTorExitNode: boolean;
  isVpn: boolean;
  isProxy: boolean;
  abuseScore: number;         // 0-100, from AbuseIPDB
  recentFailedLogins: number; // your own data
}

function scoreIpReputation(rep: IpReputation): number {
  let score = 0;
  if (rep.isDatacenter) score += 30;
  if (rep.isTorExitNode) score += 50;
  if (rep.isVpn) score += 15;
  if (rep.isProxy) score += 20;
  score += rep.abuseScore * 0.3;
  score += Math.min(rep.recentFailedLogins * 5, 40);
  return Math.min(score, 100);
}

Risk-based step-up, not hard blocks

The key insight: don't hard-block on risk score. Present friction proportional to the score:

  • Score 0–30: Allow login normally
  • Score 31–60: Require email OTP as a step-up challenge
  • Score 61–80: Rate limit heavily — one attempt per 30 seconds
  • Score 81–100: Block with a Cloudflare-style challenge or a CAPTCHA as last resort

This means legitimate users from datacenter IPs (developers, corporate proxies) aren't blocked — they just get an extra OTP step. And it means your CAPTCHA friction is reserved for the cases where it's actually needed, rather than annoying every single user.

Behavioral signals degrade over time as bot operators study your detection. Treat your scoring logic as a secret and rotate the specific signals periodically. The JS challenge approach with proof-of-work is the most durable signal because it imposes a real computational cost that's hard to avoid without executing real JavaScript.