Security

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.

Credential stuffing detection in your logs. Cover graphic for a guide to detecting credential stuffing on Node API login routes.

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.

js
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.

js
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:

sql
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:

sql
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 429 with Retry-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