Back to Blog
Feb 3, 20264 min readOnuzulike Anthony

Breach Detection: Monitoring for Credential Attacks

HaveIBeenPwned integration, credential stuffing signals, login anomaly detection, and how to rate-limit by credential type without annoying legitimate users.

SecuritySecurityAuthMonitoring

Authentication endpoints are the most abused surface on any web application. Most attacks fall into two categories: credential stuffing (testing known username/password pairs from data breaches) and brute force (guessing passwords for a known account). Detecting and responding to these requires monitoring beyond simple rate limiting.

The HaveIBeenPwned API

Troy Hunt's HIBP API lets you check whether a password has appeared in known data breaches. The k-Anonymity model lets you check without sending the full password hash:

ts
async function isPwnedPassword(password: string): Promise<boolean> {
  const hash = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
  const prefix = hash.slice(0, 5);
  const suffix = hash.slice(5);
 
  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
  const text = await res.text();
 
  return text.split('\n').some(line => {
    const [hashSuffix, count] = line.split(':');
    return hashSuffix.trim() === suffix && parseInt(count) > 0;
  });
}

Only the first 5 characters of the hash are sent. HIBP returns all hashes with that prefix; you check locally. This is a strong signal at registration: if a user tries to register with a known-breached password, reject it with an explanation.

Credential Stuffing Signals

Credential stuffing looks like normal login traffic at the individual request level. The signal is in the aggregate:

High failure rate across many accounts. A legitimate user fails 3–5 times; an attacker fails hundreds of times across different accounts from the same IP or IP range.

Unusual timing patterns. Automated attacks often fire requests at a fixed interval. Human login attempts have variable timing.

Known-bad IP ranges. Datacenter ASNs (AWS, GCP, Hetzner) rarely appear in legitimate user logins for consumer apps. A login from AS16509 (Amazon EC2) on a consumer streaming app is suspicious.

Credential pairs from known breach datasets. If the same email appears as a failed login 20 times in an hour across your platform, it's being stuffed.

Detection Implementation

Track failed login attempts in a sliding window, keyed by multiple dimensions:

ts
async function recordFailedLogin(email: string, ip: string) {
  const pipe = redis.pipeline();
  const now = Date.now();
  const window = 15 * 60 * 1000; // 15 minutes
 
  // Per-account tracking
  pipe.zadd(`login:fail:email:${email}`, now, `${now}`);
  pipe.zremrangebyscore(`login:fail:email:${email}`, 0, now - window);
  pipe.zcard(`login:fail:email:${email}`);
 
  // Per-IP tracking
  pipe.zadd(`login:fail:ip:${ip}`, now, `${email}:${now}`);
  pipe.zremrangebyscore(`login:fail:ip:${ip}`, 0, now - window);
  pipe.zcard(`login:fail:ip:${ip}`);
 
  const results = await pipe.exec();
  const perAccountFailures = results[2][1] as number;
  const perIpFailures = results[5][1] as number;
 
  if (perAccountFailures > 10) await lockAccount(email);
  if (perIpFailures > 50) await blockIp(ip);
}

Account Lockout vs Soft Blocking

Hard account lockout (requiring email verification to unlock) is disruptive for legitimate users who mistype their password repeatedly. Consider a softer approach:

  1. After 5 failures: require CAPTCHA for the next attempt.
  2. After 10 failures: impose a 30-minute cooldown.
  3. After 20 failures: lock the account and notify the user by email.

The email notification is important — it alerts legitimate users to the attack and lets them change their password proactively.

Notification on New Device/Location

Track login fingerprints (IP geolocation region, device type) per user. When a successful login comes from a new combination, send a notification email:

Someone logged into your account from a new location.
Location: Lagos, Nigeria
Device: Chrome on Windows
Time: January 20, 2026 at 14:32 UTC

If this was you, no action needed.
If this wasn't you, secure your account: [link]

This catches account takeovers where an attacker successfully authenticated. Even if you can't prevent it, the notification gives the user a chance to respond.

Rate Limiting by Credential Type

The naive approach — rate limit by IP — is ineffective against distributed attacks and punishes users on shared IPs (offices, campuses). Better strategy:

  • By IP: 20 failed attempts/15 minutes. Triggers CAPTCHA.
  • By email: 10 failed attempts/15 minutes. Triggers lockout warning email.
  • By IP + User-Agent: 50 failed attempts/hour. Blocks the IP.
  • Global failure rate: If platform-wide failure rate exceeds 5× baseline, enable CAPTCHA globally.

The global signal catches attacks that stay below per-IP thresholds by rotating through many IPs.

Breach detection is ultimately about signal aggregation. No single signal is reliable; the combination of per-account failures, per-IP failures, timing patterns, and behavioral anomalies gives you a picture that's hard to evade without making the attack too expensive to run.