Credential stuffing detection for Node APIs: spotting it in your login logs
What to log on your login route, the signals that give a stuffing run away, and SQL that finds hijacked accounts.

The first sign is usually a support ticket: a customer asks why their account shows a login they did not make. You check the login route and see nothing alarming, because every request was a well-formed login with a real email and a password that worked somewhere else.
Credential stuffing detection comes down to looking at login attempts in aggregate instead of one at a time. The strongest signals are a rising failure ratio, a spike in failures for accounts that do not exist, and single sources trying many different accounts. This post shows what to log on your login route to see those signals, plus the code and queries that turn them into an alert.
What is credential stuffing, and why is it hard to detect?
Brute force hammers one account with many passwords. Credential stuffing tries leaked email and password pairs from other breaches, usually one or two attempts per account, spread across many IPs. Per-account lockouts barely trigger, and per-IP limits only catch the clumsy operators.
It is common. Verizon’s 2025 DBIR research found that in the median case credential stuffing made up 19% of SSO authentication attempts in its data, 12% for small businesses, with a single-day peak of 44%. The same research found that in the median case only 49% of a user’s passwords across services were distinct. You do not need to be a big target. The attacker’s list picks you.
OWASP files this as automated threat OAT-008, and its Credential Stuffing Prevention Cheat Sheet is the best reference for defenses. This post covers detection, because you cannot tune a defense you cannot see.
Credential stuffing signals in request logs
| Signal | Normal traffic | Stuffing run |
|---|---|---|
| 401 to 200 ratio on login | Low, stable | Many times your baseline |
| Failures for unknown accounts | Rare typos | Large share of failures |
| Distinct accounts per IP or ASN | One or two | Dozens or hundreds |
| Attempts per account | Several on a bad day | One or two |
| Client headers | Your frontend’s headers | Missing or identical everywhere |
| Successes | Spread across users | A few from the same noisy sources |
The last row is the one that matters most. A handful of 200s from the IP ranges that produced thousands of 401s are compromised accounts.
How to log login attempts for credential stuffing detection
Most of these signals need fields a default access log does not have. This Express 5 handler records an outcome you can aggregate, without logging the password or the raw email.
import express from 'express';
import { createHmac } from 'node:crypto';
import pino from 'pino';
const log = pino();
const app = express();
app.use(express.json({ limit: '10kb' }));
app.set('trust proxy', 1); // only behind exactly one proxy you control
const PEPPER = process.env.LOGIN_LOG_PEPPER;
const accountKey = (email) =>
createHmac('sha256', PEPPER)
.update(String(email).trim().toLowerCase())
.digest('hex')
.slice(0, 16);
app.post('/v1/auth/login', async (req, res) => {
const { email, password } = req.body ?? {};
const user = await findUserByEmail(email);
const ok = user ? await verifyPassword(user, password) : false;
const outcome = !user ? 'unknown_user' : ok ? 'success' : 'bad_password';
log.info({
event: 'login_attempt',
outcome,
account: accountKey(email ?? ''),
ip: req.ip,
ua: req.get('user-agent') ?? 'none',
hasClientHeader: Boolean(req.get('x-client-version')),
});
if (!ok) return res.status(401).json({ error: 'invalid_credentials' });
res.json({ token: await issueToken(user) });
});
The response is identical for an unknown user and a wrong password, so attackers cannot use your API to validate their list. Internally the log tells them apart, because “unknown user” is your strongest signal. Make sure verifyPassword still does a hash comparison when the user does not exist, or timing gives the answer away.
The HMAC with a pepper lets you count distinct accounts per IP without putting emails in logs. More on keeping personal data out of logs in what not to log.
Detecting credential stuffing in code
A small in-process detector flags a source that tries too many distinct accounts. This is a single-instance sketch. With several instances, keep the same structure in Redis.
const WINDOW_MS = 10 * 60 * 1000;
const MAX_DISTINCT_ACCOUNTS = 20;
const sources = new Map(); // ip -> { accounts, failures, first }
export function recordAttempt({ ip, account, outcome }) {
const now = Date.now();
let s = sources.get(ip);
if (!s || now - s.first > WINDOW_MS) {
s = { accounts: new Set(), failures: 0, first: now };
sources.set(ip, s);
}
s.accounts.add(account);
if (outcome !== 'success') s.failures++;
const suspicious =
s.accounts.size > MAX_DISTINCT_ACCOUNTS && s.failures / s.accounts.size > 0.8;
return { suspicious, distinct: s.accounts.size };
}
setInterval(() => {
const cutoff = Date.now() - WINDOW_MS;
for (const [ip, s] of sources) if (s.first < cutoff) sources.delete(ip);
}, 60_000).unref();
When suspicious is true, log and alert before you block. Stuffing often comes through residential proxies, where one IP can also carry real users.
SQL queries to find credential stuffing
If your logs land in a queryable login_attempts table, ask two questions. Failure ratio by hour:
SELECT date_trunc('hour', ts) AS hour,
count(*) FILTER (WHERE outcome <> 'success')::float
/ NULLIF(count(*) FILTER (WHERE outcome = 'success'), 0) AS fail_ratio,
count(*) FILTER (WHERE outcome = 'unknown_user') AS unknown_users
FROM login_attempts
WHERE ts > now() - interval '7 days'
GROUP BY 1 ORDER BY 1;
Sources trying many accounts:
SELECT ip, count(DISTINCT account) AS accounts, count(*) AS attempts,
count(*) FILTER (WHERE outcome = 'success') AS successes
FROM login_attempts
WHERE ts > now() - interval '1 hour'
GROUP BY ip
HAVING count(DISTINCT account) > 20
ORDER BY accounts DESC;
The successes column is your incident list: lock those accounts, force a reset, and review their activity since the login.
How to stop credential stuffing once you see it
- Step up successful logins from suspicious sources to a second factor or email confirmation instead of blocking the source.
- Rate limit the login route per IP and per account, plus a global ceiling on unknown-user failures. Rate limiting algorithms compared covers which one to pick.
- Check new passwords against known breached passwords at signup and reset.
- Require MFA for admin and API key management.
For a one-person team, the afternoon version is the logging change, the second SQL query as a daily scheduled job, and a login rate limit. A ten-person team should add the in-process or Redis detector, alerting on the failure ratio, and a written runbook for locking compromised accounts.
If you only do one thing
Log every login attempt with an outcome of success, bad_password or unknown_user, an HMAC of the account and the client IP. Every other step on this page depends on it.
FAQ
How do I know if my API is under a credential stuffing attack?
Compare today’s login failure ratio and unknown-account failures with your normal baseline, and look for single IPs or networks trying many distinct accounts. A sudden jump in any of those, especially with a few successes from the same sources, is the pattern.
What is the difference between credential stuffing and brute force?
Brute force guesses many passwords for one account. Credential stuffing tries one known password per account across many accounts, which is why per-account lockouts rarely catch it.
Does rate limiting stop credential stuffing?
It slows it down but rarely stops it, because attackers spread requests across many IPs. Combine rate limits with detection on distinct accounts per source and MFA.
Doing this with ApiTraffic
If you would rather not build this pipeline yourself, ApiTraffic captures every request to your login route from one middleware, with Authorization, Cookie and X-Api-Key masked by default. Add a redaction rule for the password field in the dashboard and it is pushed to your servers without a deploy.
- Search for bursts:
url_path == "/v1/auth/login" AND status_code == 401. - Behavioral protection scores each caller on signals including 401/403 counts and distinct endpoints, and gives callers over the threshold a temporary deny on every server.
- A rate limit on the login path returns
429withRetry-After, and access lists can deny by IP, CIDR, country or ASN. Monitor mode shows what a policy would block first.
Start free and see every request your API handles: apitraffic.io
Sources: Verizon, “Additional 2025 DBIR research on credential stuffing” (2025); OWASP Credential Stuffing Prevention Cheat Sheet; OWASP Automated Threats, OAT-008 Credential Stuffing
Keep reading
Node.js rate limiting: fixed window vs sliding window vs token bucket
One client's runaway retry loop should not take your API down at 3am. Compare fixed window, sliding window and token bucket rate limiting in Node, with working code, a Redis version and what to ship this afternoon.
API key security for Node APIs: prefixes, hashing, scopes and rotation
A customer just pushed their API key to a public repo. Can you tell if anyone used it, and could a database leak expose every key? Design API key security in Node properly: prefixes, hashing, scopes and rotation.
BOLA vulnerability in Node APIs: how to fix IDOR and catch it in your logs
Change one digit in a URL and get another customer's invoice. Here is how to fix BOLA and IDOR structurally and spot someone probing for it before they find a gap.