Olympus Docs
CookbookDefensive security

Tuning login rate limits

Find the right thresholds for brute-force defense vs UX

Too strict: real users locked out after fat-fingering. Too lax: brute force succeeds. The tuning depends on your traffic patterns.

Defaults

Olympus ships with:

@login_post path /self-service/login method POST
rate_limit @login_post {
  zone login
  events 10
  window 1m
  key {http.request.remote_host}
}

Per-IP: 10 login attempts per minute. Generous for real users, restrictive for trivial brute force.

Account-level lockout

In Kratos:

selfservice:
  methods:
    password:
      config:
        # After 5 wrong passwords, lock for 5 min

(Olympus has custom logic for this; configure per requirements.)

Tuning per scenario

B2C consumer app

  • IP rate: 10/min (default).
  • Account lockout: 5 attempts, 15-min cooldown.
  • After cooldown, attempts reset.

Real users rarely fat-finger more than 2-3 times.

B2B enterprise

  • IP rate: higher (corporate NAT, many users from same IP).
  • Account lockout: 10 attempts, 30-min cooldown.

Adjust if behind Cloudflare, etc., where many users share egress IP.

Public API endpoints

  • Per-IP and per-token rate limits.
  • Lower thresholds (API consumers should know their limits).
@api method POST
rate_limit @api {
  events 1000
  window 1m
  key {http.request.header.authorization}
}

Healthcare / financial

  • Stricter: 3 attempts before lockout.
  • Longer cooldown: 24h.
  • Notify user on each failure.

Don't enable enumeration

Account lockout must NOT differ in observable behavior between "valid user, wrong password" and "non-existent user."

GOOD:
- alice@example.com / wrong → "Invalid credentials"
- nonexistent@x.com / anything → "Invalid credentials"

BAD:
- alice@example.com / wrong (lockout) → "Account temporarily locked"
- nonexistent@x.com / anything → "Invalid credentials"

The second reveals which emails are registered.

Olympus handles this, same response, same timing. Don't customize in ways that break it.

Distinguishing types of failures

When deciding lockout:

  • Wrong password → count toward lockout.
  • Account not found → don't count (user might be typoing email).
  • MFA failure → separate counter.
async function recordFailedLogin(email: string, type: string) {
  const id = await findIdentityByEmail(email);
  if (!id) return;  // don't lock nonexistent account
  
  if (type === "wrong_password") {
    await redis.incr(`failed_pw:${id}`);
    await redis.expire(`failed_pw:${id}`, 900);  // 15 min
  } else if (type === "mfa_failure") {
    await redis.incr(`failed_mfa:${id}`);
    await redis.expire(`failed_mfa:${id}`, 900);
  }
}

Different thresholds:

  • Wrong password: 5 → lock 15 min.
  • MFA failure: 10 → lock 1 hour.

Per-source rate limit

Beyond IP, consider:

  • Per-account: lock after N attempts. Per-account counter.
  • Per-IP + account: locks if same IP attacks N accounts.
  • Per-ASN: if AS-level attack from one cloud.
async function shouldBlock(identityId, ip) {
  const ipFails = await redis.get(`fails_ip:${ip}`);
  const idFails = await redis.get(`fails_id:${identityId}`);
  const asnFails = await redis.get(`fails_asn:${ipToAsn(ip)}`);
  
  return ipFails > 20 || idFails > 5 || asnFails > 100;
}

Layered. One signal alone might be false positive; multiple converge.

Time-of-day patterns

Real users follow circadian patterns. Logins peak ~9 AM in user's timezone.

SELECT EXTRACT(hour FROM created_at AT TIME ZONE 'UTC') AS hour, COUNT(*)
FROM security_audit
WHERE event_type = 'login' AND outcome = 'success'
GROUP BY 1
ORDER BY 1;

If logins are highest 9-17 in your customers' TZ, and you see 3 AM bursts: anomaly.

Lockout messaging

Bad:

"Account locked. Try again in 15 min."

This says "try later", invites a wait-and-retry attacker.

Better:

"Too many failed attempts. Try resetting your password instead:"
[Reset password]

User goes through recovery → password reset → defeats stuck-on-old-password issue.

OR: if they keep failing, offer support contact: support@your-domain.com.

Notifications on lockout

Email the user when their account is locked:

Subject: Your account was temporarily locked

Hi,

Multiple failed sign-in attempts triggered a temporary lock on your account.
The lock auto-clears in 15 minutes.

If this wasn't you, change your password now (link expires in 1 hour):
  {{ resetLink }}

If you're locked out due to forgetting your password, use the reset
link above to set a new one.

Educates user. Gives a way forward.

Pre-emptive password reset

For users with N failed attempts:

if (await failedAttempts(identityId) >= 3) {
  await sendEmail(user.email, "Reset your password?", ...);
}

User receives email mid-attempts. Realizes "oh, I forgot. Let me reset."

Resetting counters

After successful login, reset:

async function onSuccessfulLogin(identityId) {
  await redis.del(`failed_pw:${identityId}`);
  // Don't reset MFA failed counter (paranoid: phish then MFA bypass)
}

Or reset everything. Trade-off.

Logging

INSERT INTO security_audit (event_type, identity_id, source_ip, outcome, metadata)
VALUES ('login', $id, $ip, 'failure', '{"reason": "wrong_password", "count_so_far": 3}');

Helps tuning later, was lockout effective? Did real users see it?

On this page