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' });
});