Olympus Docs
CookbookDefensive security

Defending against password spraying

Low-and-slow distributed credential attacks

Password spraying: attacker tries ONE common password (Spring2026!) against MANY accounts. Slow per-account = no lockout triggered. Aggregated = highly effective on weak passwords.

Why traditional defenses miss it

  • Per-account lockout: each account sees only 1 attempt.
  • Per-IP rate limit: attacker uses distributed proxies / residential IPs.
  • CAPTCHA: per-IP rate-limited triggers don't fire.

Olympus's account lockout and Caddy rate limiter don't catch sprays alone. You need aggregate detection.

Detection signals

Signal A: Burst of failed logins across many identities

SELECT
  DATE_TRUNC('minute', created_at) AS minute,
  COUNT(DISTINCT identity_id) AS unique_identities,
  COUNT(*) AS attempts
FROM security_audit
WHERE event_type = 'login'
  AND outcome = 'failure'
  AND created_at > NOW() - INTERVAL '5 minutes'
GROUP BY 1;

Normal: few failures across many identities. Spray: many identities each with one failure → high unique_identities.

Signal B: Same password across many accounts

If you could log the hashed-input password (don't!), you'd see the same value across many identities, clear spray.

You can't safely log this. Instead: check passwords for being on a "known sprayed list" (e.g., HaveIBeenPwned breached passwords).

Signal C: User-Agent / IP clustering

Sprays often come from a small pool of UAs and ASNs.

SELECT 
  source_ip, 
  user_agent, 
  COUNT(DISTINCT identity_id) AS spread,
  COUNT(*) AS attempts
FROM security_audit
WHERE event_type = 'login' AND outcome = 'failure'
  AND created_at > NOW() - INTERVAL '1 hour'
GROUP BY 1, 2
HAVING COUNT(DISTINCT identity_id) > 10
ORDER BY spread DESC;

IPs touching >10 different accounts in an hour → likely spray.

Responses

Auto-block suspicious IPs

// scheduled job, every minute
const suspects = await db`
  SELECT source_ip
  FROM security_audit
  WHERE outcome = 'failure'
    AND created_at > NOW() - INTERVAL '15 minutes'
  GROUP BY source_ip
  HAVING COUNT(DISTINCT identity_id) > 10
`;

for (const { source_ip } of suspects) {
  await ipBlocker.add(source_ip, "spray_detected", "1h");
}

Caddy reads the block list (from a file or service):

@blocked file /etc/caddy/blocked-ips
respond @blocked 403

Stronger MFA enforcement

After a spray event, push users with weak passwords toward MFA:

const sprayed = await db`
  SELECT identity_id 
  FROM security_audit 
  WHERE event_type = 'login' AND outcome = 'failure'
    AND created_at > NOW() - INTERVAL '24 hours'
`;
for (const { identity_id } of sprayed) {
  await sendMfaPrompt(identity_id, "Your account was targeted; consider enabling 2FA");
}

Increase password requirements

If you allowed weak passwords, audit:

  • Check existing users' password strength (you can't read; but you can require change on next login).
  • Force re-set with strong requirements.
await kratos.adminPatchIdentity(identity_id, [
  { op: "replace", path: "/metadata_public/require_password_change", value: true }
]);

Hera reads this metadata on next login and prompts password change.

Proactive prevention

Block common passwords at signup

Use HIBP API or a local breached-password list:

async function isBreached(password: string): Promise<boolean> {
  const hash = sha1(password).toUpperCase();
  const prefix = hash.slice(0, 5);
  const suffix = hash.slice(5);
  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
  return (await res.text()).includes(suffix);
}

if (await isBreached(traits.password)) {
  return Response.json({ reject: true, error: "breached_password" });
}

Olympus supports this; ensure it's enabled.

Minimum password length / strength

selfservice:
  methods:
    password:
      config:
        min_password_length: 12
        identifier_similarity_check_enabled: true
        haveibeenpwned_enabled: true

Mandatory MFA

The strongest defense. Even if password is sprayed, MFA blocks.

For high-value accounts (admin, paying customer), make MFA required at signup. See Feature flag MFA rollout.

Lockout adjustments

Olympus's per-account lockout (5 fails in 5 min) doesn't catch sprays, but you can add an aggregate variant:

// Aggregate failed-attempts threshold for a specific IP
const ipFailures = await redis.incr(`spray:${ip}`);
await redis.expire(`spray:${ip}`, 900); // 15 min
if (ipFailures > 50) {
  await ipBlocker.add(ip, "aggregate_failures", "1h");
}

50 failures from one IP in 15 min, definitely spray. Block.

When to alert humans

Auto-block is fast but blunt. Alert security team when:

  • 100 IPs blocked in last hour.

  • 1000 unique identities targeted.

  • Geographic concentration (suggesting coordinated).
  • Targeted at admin accounts specifically.

A bot-detected spray is one thing; a targeted one (specific to your service) is worse.

Incident communication

If a spray succeeded against any account, that account is compromised. Standard ATO response: see Account takeover response.

If a spray was attempted but didn't succeed, no individual notification needed, but consider a blog post about defenses you have if customers might worry.

On this page