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 403Stronger 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: trueMandatory 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.