Credential stuffing attacks: detection, mitigation, and the HIBP integration that actually helps

Credential stuffing is the automated replay of username/password combinations sourced from prior data breaches against a target site. The attacker does not need to break your password hashing — they already have the plaintext passwords from a breach of a different service where users reused credentials. When a 2 billion-record combo list surfaces on a hacking forum, every service where users reused those credentials is at risk simultaneously. This is not a fringe attack: credential stuffing represents the majority of auth-related attacks at scale.

Why rate limiting alone fails

Credential stuffing operations are not naive. Professional botnet operators use rotating residential proxy networks, distributed request timing, and valid browser fingerprints to evade simple rate limiting. A modern stuffing campaign might make 1 request per target account per 24 hours, spread across 50,000 IPs, which is well within any per-IP rate limit you might set.

The key distinction from brute-force attacks is that stuffing uses valid credentials — typically the correct password for a small percentage of accounts. The per-account failure rate is low (attackers discard accounts after one failed attempt). Traditional account lockout based on consecutive failures catches brute force but misses stuffing entirely.

Device fingerprinting

Device fingerprinting collects passive signals from the browser or client to build a consistent identifier that persists across sessions without relying on a stored cookie. For credential stuffing detection, the key signals are:

  • TLS fingerprint (JA3 hash): the TLS client hello configuration identifies the client software. A bot using a Python requests library has a different TLS fingerprint than a real Chrome browser.
  • HTTP/2 fingerprint: the SETTINGS frame parameters in an HTTP/2 connection differ between real browsers and bot frameworks.
  • User-Agent consistency: a claimed Chrome 114 UA should have a matching Sec-CH-UA header, a matching TLS fingerprint, and browser-consistent JavaScript behavior.
  • Behavioral biometrics: mouse movement patterns, keystroke timing, and scroll behavior during form fill indicate human versus bot interaction.
// Collect and hash browser fingerprint signals on the client
async function collectFingerprint() {
  const signals = {
    screen: `${screen.width}x${screen.height}x${screen.colorDepth}`,
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    languages: navigator.languages.join(','),
    platform: navigator.platform,
    cookiesEnabled: navigator.cookieEnabled,
    doNotTrack: navigator.doNotTrack,
    hardwareConcurrency: navigator.hardwareConcurrency,
    deviceMemory: (navigator as any).deviceMemory,
    canvas: await getCanvasFingerprint(),
    webgl: await getWebGLFingerprint(),
    fonts: await getFontList(),  // CSS-based font enumeration
    audioContext: await getAudioFingerprint(),
  };

  // Hash the signals to a stable identifier
  const msgBuffer = new TextEncoder().encode(JSON.stringify(signals));
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

// Include fingerprint in the login request
const fingerprint = await collectFingerprint();
await fetch('/auth/login', {
  method: 'POST',
  body: JSON.stringify({ email, password, fp: fingerprint }),
  headers: { 'Content-Type': 'application/json' },
});

Velocity checks across dimensions

Effective velocity checking looks at multiple dimensions simultaneously. A single metric is easy to evade; the combination is much harder.

async function checkVelocity(email, ip, fingerprint, userAgent) {
  const now = Date.now();
  const windows = { '5m': 300, '1h': 3600, '24h': 86400 };

  const checks = await Promise.all([
    // Per-account failures across all IPs
    redis.zcount(`vel:acct:${email}`, now - windows['1h'] * 1000, now),

    // Per-IP across all accounts
    redis.zcount(`vel:ip:${ip}`, now - windows['5m'] * 1000, now),

    // Per-fingerprint across all accounts
    redis.zcount(`vel:fp:${fingerprint}`, now - windows['24h'] * 1000, now),

    // Global failure rate (are we under attack?)
    redis.get('vel:global:failures:1m'),
  ]);

  const [acctFailures1h, ipRequests5m, fpRequests24h, globalRate] = checks;

  const riskScore =
    (acctFailures1h > 5 ? 30 : 0) +
    (ipRequests5m > 20 ? 20 : 0) +
    (fpRequests24h > 50 ? 25 : 0) +
    (parseInt(globalRate || '0') > 1000 ? 15 : 0);

  return {
    riskScore,
    action: riskScore >= 60 ? 'block' : riskScore >= 30 ? 'challenge' : 'allow',
  };
}

Breached password checking on login with HIBP

The Have I Been Pwned Pwned Passwords API lets you check whether a password has appeared in a known breach without sending the password (or even a full hash) to the HIBP service. The k-Anonymity model: hash the password with SHA-1, send the first 5 hex characters to the API, receive back all hashes in that prefix range, and check whether the full hash is in the response. The API never sees more than 5 characters of the hash.

async function isPasswordBreached(password: string): Promise<boolean> {
  const encoder = new TextEncoder();
  const data = encoder.encode(password);
  const hashBuffer = await crypto.subtle.digest('SHA-1', data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();

  const prefix = hashHex.slice(0, 5);
  const suffix = hashHex.slice(5);

  const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
    headers: { 'Add-Padding': 'true' },  // Prevents traffic analysis
  });

  if (!response.ok) {
    // Fail open — don't block login if HIBP is unavailable
    return false;
  }

  const text = await response.text();
  const lines = text.split('\n');

  for (const line of lines) {
    const [hashSuffix, countStr] = line.split(':');
    if (hashSuffix.trim() === suffix) {
      const count = parseInt(countStr.trim(), 10);
      return count > 0;
    }
  }
  return false;
}

// Use during login: if breached, force password change
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await validateCredentials(email, password);

  if (user) {
    const breached = await isPasswordBreached(password);
    if (breached) {
      // Do not block login, but force a password change
      return res.json({
        requiresPasswordChange: true,
        reason: 'password_found_in_breach_database',
        session: await issueTemporaryPasswordChangeSession(user.id),
      });
    }
    // Normal login flow
    return res.json({ session: await issueSession(user) });
  }

  res.status(401).json({ error: 'invalid_credentials' });
});
Checking for breached passwords on login (not just at registration) catches the case where a user's password was only breached after they set it on your site. A password that was unique when created can appear in a breach dataset months or years later. Running the check on every successful login — with result caching keyed to the password hash to avoid hitting HIBP repeatedly — catches these cases proactively.
← Back to blog Try Bastionary free →