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.