Exponential Backoff Lockout: Stopping Brute Force Without Leaking Account Existence

In the previous article, we saw how to normalize login timing to prevent enumeration via timing oracle. But even with constant timing, an attacker can still brute-force passwords. They just need time.

Lockout is the standard answer. Except most implementations I've seen have at least one of three problems: they leak account existence, they don't reset correctly, or they use a fixed delay that's either too short (useless) or too long (denial of service on real users).

The pattern: capped exponential backoff

The first N failures are silent — no lockout, just an incremented counter. Beyond N, the account is locked for 1 << (failures - N) seconds, capped at 15 minutes.

FailuresDelay (N=5)
1-50 (silent)
62s
74s
88s
916s
1032s
1164s
12128s
13256s
14512s
15+900s (15 min cap)

Exponential is the right trade-off: a typo on the 6th attempt costs 2 seconds. A brute force at the 15th attempt costs 15 minutes per try. The attacker effort / user annoyance ratio is maximal.

func lockoutDuration(failures, threshold int) time.Duration {
    if failures <= threshold {
        return 0
    }
    shift := failures - threshold
    seconds := 1 << shift // 2, 4, 8, 16, 32...
    if seconds > 900 {
        seconds = 900 // cap at 15 min
    }
    return time.Duration(seconds) * time.Second
}

The status code trap

I've seen implementations returning 423 Locked when the account is locked and 401 Unauthorized when the password is wrong. Good intention. Catastrophic result: the attacker knows the account exists.

The rule: the status code must never distinguish "locked" from "wrong credentials". Always 401, always the same message. The attacker doesn't know if the account exists, is locked, or if the password is wrong.

func (h *LoginHandler) Handle(email, password string) error {
    user, err := h.repo.FindByEmail(email)
    if err != nil {
        argon2.CompareHashAndPassword(h.dummyHash, []byte(password))
        return ErrInvalidCredentials // same error
    }

    if h.isLocked(user) {
        argon2.CompareHashAndPassword(h.dummyHash, []byte(password))
        return ErrInvalidCredentials // same error, same timing
    }

    if !argon2.CompareHashAndPassword(user.PasswordHash, []byte(password)) {
        h.incrementFailures(user)
        return ErrInvalidCredentials // same error
    }

    h.resetFailures(user)
    return nil
}

Note the dummy hash on the "locked" case too. Same timing as the other branches. We combine the Argon2 dummy hash pattern with lockout.

The reset: three cases, one missed is enough

The failure counter must be reset in exactly three situations:

  1. Successful login — the user proved they know the password
  2. Password change — by the user themselves
  3. Admin reset — an admin manually unlocks the account

I saw a codebase where reset only happened on successful login. Result: a user who changes their password (via "forgot password" flow) keeps their old counter. At the next typo, they're immediately locked at whatever level they were at before the reset. Not the expected behavior.

// In the change password command handler
func (h *ChangePasswordHandler) Handle(cmd ChangePasswordCmd) error {
    // ... validation, hashing new password ...

    user.PasswordHash = newHash
    user.FailedAttempts = 0      // mandatory reset
    user.LockedUntil = time.Time{} // unlock

    return h.repo.Save(user)
}

Lockout on unknown users: persist nothing

A subtler trap: if you persist login failures for users that don't exist, a brute-forcer can pollute your table. 10 million attempts on random emails = 10 million rows in your lockout table.

For unknown users: slog and nothing else. The per-IP rate limiter (upstream) handles volume. Lockout is per-account, not per random attempt.

Audit log: what to trace, what not to

Login failures on existing accounts are business events — they feed the counter and deserve an audit log entry. Login failures on non-existent accounts are noise — slog.Warn and move on.

If you trace both in the same table, you give an attacker a pollution vector for your audit table. And you make real incident analysis harder.

Conclusion

Exponential backoff lockout is a deceptively simple pattern. The subtleties are in the details: same status code everywhere, same timing everywhere, reset on all three cases, no persistence for unknown users.

Combined with the dummy hash, you have a login endpoint that leaks neither account existence nor lock state, and resists brute force with progressive cost for the attacker.

The login is now solid. But authenticated requests that follow have their own attack surface. CSRF, for example: the double-submit cookie everyone uses is insufficient for certain contexts. That's the subject of the next article.

Comments (0)