Credential Stuffing: How It Works and How to Stop It
Credential stuffing uses leaked username/password pairs to compromise accounts at scale. Learn the detection signals, mitigation controls, and how to use HaveIBeenPwned to protect your users.
In October 2023, a credential stuffing attack against 23andMe resulted in the exposure of genetic data for nearly 7 million users. The attackers didn't breach 23andMe's systems — they used valid usernames and passwords obtained from completely unrelated data breaches. Once in those accounts, they accessed data shared through the DNA Relatives feature, amplifying the damage far beyond the directly compromised accounts.
This is credential stuffing. It's not a sophisticated attack. It's database table + HTTP requests + patience. And it's devastatingly effective because most people reuse passwords.
Credential Stuffing vs. Brute Force
These are often confused. Understanding the distinction matters for choosing the right defense.
Brute force tries many passwords against one or a few accounts. The attacker doesn't know the correct password and is trying to discover it. Defenses: account lockout after N failures, CAPTCHA.
Credential stuffing uses known username/password pairs from breaches. The attacker already has valid credentials — they're just testing which accounts they still work on. The attacker doesn't care about any specific account; they're running millions of pairs and accepting a 1-3% success rate.
Against credential stuffing, per-account lockout is largely ineffective. Each account might only receive one or two login attempts — not enough to trigger lockout. The attack succeeds through volume across a massive number of accounts.
The Scale of the Problem
The credential stuffing ecosystem operates on a massive scale:
- Major breach compilations contain billions of username/password pairs. "Collection #1" (2019) contained 773 million unique emails. More recent compilations are larger.
- Automated tools (Sentry MBA, OpenBullet, SNIPR) make credential stuffing accessible to low-skill attackers.
- "Checker" services let attackers test credentials at scale, then sell valid hits.
- A 1% success rate on 10 million pairs = 100,000 compromised accounts.
Detection Signals
Credential stuffing attacks leave distinctive fingerprints. Detecting them requires logging enough data to identify the patterns.
High-Volume Login Failures From a Single IP
The most obvious signal, but sophisticated attackers distribute across thousands of IPs using residential proxy networks. Still worth monitoring:
// Rate limiting by IP (Redis-based sliding window)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL });
const loginRateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '60 s'), // 10 attempts per minute per IP
prefix: 'ratelimit:login',
analytics: true,
});
export async function handleLogin(req, res) {
const ip = req.headers['x-forwarded-for']?.split(',')[0] ?? req.socket.remoteAddress;
const { success, limit, remaining, reset } = await loginRateLimiter.limit(ip);
if (!success) {
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', reset);
return res.status(429).json({
error: 'Too many login attempts. Please try again later.',
});
}
// Proceed with authentication...
}
Distributed Attacks: Look at Failure Rate Across the Application
When attacks are distributed across many IPs, look at the global authentication failure rate rather than per-IP counts:
// Monitor global failure rate with a time-windowed counter
async function trackLoginAttempt(success: boolean) {
const minuteKey = `login_attempts:${Math.floor(Date.now() / 60000)}`;
await redis.hincrby(minuteKey, success ? 'success' : 'failure', 1);
await redis.expire(minuteKey, 300); // Keep 5 minutes of data
}
async function getFailureRate(): Promise<number> {
const minuteKey = `login_attempts:${Math.floor(Date.now() / 60000)}`;
const counts = await redis.hgetall(minuteKey);
const success = parseInt(counts?.success ?? '0');
const failure = parseInt(counts?.failure ?? '0');
const total = success + failure;
if (total < 10) return 0; // Not enough data
return failure / total;
}
// Alert when failure rate exceeds threshold
async function checkAttackSignals() {
const failureRate = await getFailureRate();
if (failureRate > 0.5) { // > 50% failure rate is suspicious
await alertSecurityTeam({
type: 'high_failure_rate',
failureRate,
message: `Login failure rate is ${(failureRate * 100).toFixed(1)}%`,
});
}
}
User-Agent Analysis
Credential stuffing tools often use:
- Headless browser user agents in bulk
- The same user agent string across thousands of requests
- Outdated browser versions that real users don't run
function isLikelyBot(userAgent: string): boolean {
// Common tool signatures
const botSignatures = [
/python-requests/i,
/go-http-client/i,
/okhttp/i,
/curl\//i,
/wget\//i,
/OpenBullet/i,
/SentryMBA/i,
];
return botSignatures.some(pattern => pattern.test(userAgent));
}
Velocity Checks at Multiple Dimensions
Monitor for anomalies across multiple dimensions simultaneously:
interface LoginAttempt {
email: string;
ip: string;
userAgent: string;
success: boolean;
timestamp: Date;
}
async function detectCredentialStuffing(attempt: LoginAttempt): Promise<{
blocked: boolean;
reason?: string;
}> {
const checks = await Promise.all([
// 1. IP velocity: too many attempts from one IP
checkIpVelocity(attempt.ip),
// 2. User-Agent uniqueness: many IPs using the same UA
checkUserAgentAnomalies(attempt.userAgent),
// 3. Email domain distribution: balanced distribution across many domains
// Real users tend to cluster on popular domains; stuffing hits everything equally
checkEmailDomainDistribution(),
// 4. ASN velocity: attacks often come from the same ISP/hosting provider
checkAsnVelocity(attempt.ip),
]);
const blocked = checks.some(c => c.flagged);
const reason = checks.find(c => c.flagged)?.reason;
return { blocked, reason: reason ?? undefined };
}
Rate Limiting Strategy
Effective rate limiting for credential stuffing needs to be multi-dimensional:
// Layer 1: Per-IP rate limiting (catches naive attacks)
const ipLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '15 m'),
prefix: 'ratelimit:ip',
});
// Layer 2: Per-account rate limiting (catches distributed attacks on specific accounts)
const accountLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '15 m'),
prefix: 'ratelimit:account',
});
// Layer 3: Global rate limiting (catches massive distributed attacks)
const globalLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10000, '1 m'),
prefix: 'ratelimit:global',
});
export async function rateLimitedLogin(req, email: string) {
const ip = getClientIp(req);
const [ipResult, accountResult, globalResult] = await Promise.all([
ipLimiter.limit(ip),
accountLimiter.limit(email),
globalLimiter.limit('global'),
]);
if (!ipResult.success) {
return { blocked: true, reason: 'Too many attempts from your IP address' };
}
if (!accountResult.success) {
return { blocked: true, reason: 'Too many attempts for this account' };
}
if (!globalResult.success) {
// Entire platform under attack — require CAPTCHA for all logins
return { captchaRequired: true };
}
return { blocked: false };
}
CAPTCHA: When and How to Use It
CAPTCHA adds friction that automated tools struggle with but humans pass easily. However, CAPTCHA on every login degrades the user experience significantly.
Risk-adaptive CAPTCHA applies it only when signals suggest automation:
async function shouldRequireCaptcha(req, email: string): Promise<boolean> {
const ip = getClientIp(req);
const userAgent = req.headers['user-agent'] ?? '';
// Known bot signatures → always require CAPTCHA
if (isLikelyBot(userAgent)) return true;
// Recently failed attempts → require CAPTCHA
const recentFailures = await countRecentFailures(ip, 5 * 60 * 1000);
if (recentFailures >= 3) return true;
// New IP with no history → require CAPTCHA after first failure
const ipHistory = await getIpHistory(ip);
if (!ipHistory.trusted && await getRecentFailureForEmail(email) > 0) return true;
return false;
}
MFA as the Ultimate Defense
All detection and rate limiting can be bypassed by a sufficiently patient attacker with a large enough IP pool. The only control that definitively stops credential stuffing is MFA.
When an attacker stuffs valid username/password credentials, MFA means they still can't get in without the second factor. The stolen password becomes worthless.
Priorities:
- Enforce MFA for all admin accounts (no exceptions).
- Strongly encourage MFA for all users.
- Use risk-adaptive step-up: require MFA on login from unfamiliar devices or locations.
HaveIBeenPwned Integration
Troy Hunt's HaveIBeenPwned (HIBP) database contains billions of breached passwords. Checking user passwords against this database at account creation or login helps identify credentials that have already been exposed.
The k-anonymity API lets you check without sending the full password hash:
import crypto from 'crypto';
async function isPasswordPwned(password: string): Promise<number> {
const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.substring(0, 5);
const suffix = sha1.substring(5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' },
});
if (!response.ok) {
// Fail open — don't block legitimate users if HIBP is down
return 0;
}
const text = await response.text();
const lines = text.split('\n');
for (const line of lines) {
const [hashSuffix, countStr] = line.split(':');
if (hashSuffix.trim() === suffix) {
return parseInt(countStr.trim(), 10); // Number of times seen in breaches
}
}
return 0; // Not found in breaches
}
// Check on registration and password change
async function validateNewPassword(password: string): Promise<{
valid: boolean;
error?: string;
}> {
const breachCount = await isPasswordPwned(password);
if (breachCount > 0) {
return {
valid: false,
error: `This password has appeared in data breaches ${breachCount.toLocaleString()} times. Please choose a different password.`,
};
}
return { valid: true };
}
The k-anonymity approach: you only send the first 5 characters of the SHA-1 hash. HIBP returns all hashes starting with those 5 characters (typically hundreds). Your client checks locally for the full suffix. HIBP never sees the full hash of your user's password.
Responding to an Active Attack
When you detect a credential stuffing attack in progress:
- Increase friction globally: Enable CAPTCHA for all login attempts.
- Notify potentially affected users: Users who had failed login attempts during the window should be alerted and encouraged to enable MFA.
- Block known attack infrastructure: IP ranges, ASNs, and user agents associated with the attack.
- Force password resets for compromised accounts: Any account that had a successful login during the attack window should be considered potentially compromised.
- Check HIBP for your user database: Use the HIBP Passwords API to identify users with known-breached passwords and prompt them to change.
Credential stuffing is a volume game. Your defenses don't need to be perfect — they need to make the economics unfavorable for the attacker.