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.
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:
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:
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:
- After 5 failures: require CAPTCHA for the next attempt.
- After 10 failures: impose a 30-minute cooldown.
- 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.