How do you actually prevent brute-force and credential-stuffing attacks against a login endpoint?
Login endpoints need layered rate limiting, lockouts, and adaptive CAPTCHA challenges, because no single control stops both brute force and distributed credential stuffing.
LEARN BY DOINGPut your intuition to work3 steps
How do you actually prevent brute-force and credential-stuffing attacks against a login endpoint?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Two different attacks, two different signatures
"Someone is trying to log in as everyone" and "someone is trying every password against one account" look similar in isolation but need different defenses:
| Brute force | Credential stuffing | |
|---|---|---|
| Target | One account (or a small set) | Thousands of accounts |
| Passwords tried | Many guesses against few usernames | One password/username pair per attempt, harvested from a prior breach |
| Source | Often a single IP or small botnet | Large, distributed pool of IPs (residential proxies, botnets) |
| What defeats it | Rate limiting + lockout on the account | Detecting reused breached credentials + device/behavior signals, since per-IP limits barely slow it down |
A defense built only around "block an IP after N failures" stops naive brute force and does almost nothing against credential stuffing, where each of ten thousand compromised IPs makes exactly one request. You need layers that key on different dimensions: IP, account, and the credential pair itself.
#Layer 1 — rate limiting, but keyed correctly
The first layer is a fixed- or sliding-window counter, incremented atomically in Redis (or an equivalent shared store — this must not live in per-instance memory behind a load balancer). Two independent counters matter:
- Per source IP — catches a single machine hammering the endpoint, regardless of which account it's targeting.
- Per account/username — catches credential stuffing and slow, low-and-slow brute force spread across many source IPs, because the attacker still has to hit the same account repeatedly (or, for stuffing, hits many accounts once each, which the account-level counter can't see — this is why layer 3 exists).
#Layer 2 — progressive lockout, not permanent lockout
A flat "5 failures, locked forever until support unlocks it" policy is itself a denial-of-service vector: an attacker who knows your victim's username can lock them out of their own account just by failing five logins on purpose, with zero need to guess the password. The fix is a progressive, temporary lockout — the delay grows with repeated failures but always expires on its own:
#Layer 3 — CAPTCHA as an adaptive, not universal, gate
Putting a CAPTCHA on every login destroys conversion for the 99.9% of legitimate users and barely slows down attackers who pay for CAPTCHA-solving services anyway. The useful pattern is adaptive: only challenge a CAPTCHA once a signal crosses a threshold — a handful of recent failures on that account, a login attempt from a new/unrecognized device fingerprint, or a spike in failure rate from an ASN. Below the threshold, the flow stays frictionless.
#A working implementation
Here's a Go guard around a login endpoint using Redis for atomic counters, combining all three layers. It's meant to run before password verification (for the rate limit and lockout checks) and after it (to record the result):
package authimport ("context""errors""fmt""time""github.com/redis/go-redis/v9")var (ErrRateLimited = errors.New("too many attempts, try again later")ErrAccountLocked = errors.New("account temporarily locked"))const (ipWindow = 1 * time.MinuteipMaxAttempts = 20 // stops a single source hammering the endpointaccountWindow = 15 * time.MinuteaccountMaxAttempts = 5lockoutBase = 30 * time.SecondcaptchaThreshold = 3 // failures before we force a CAPTCHA on the next attemptmaxLockDuration = 30 * time.Minute)type LoginGuard struct {rdb *redis.Client}func NewLoginGuard(rdb *redis.Client) *LoginGuard {return &LoginGuard{rdb: rdb}}// CheckBeforeAttempt runs BEFORE verifying the password. It never distinguishes// "unknown account" from "locked account" in its error — callers must show the// same generic message for both, or the endpoint becomes a username oracle.func (g *LoginGuard) CheckBeforeAttempt(ctx context.Context, ip, accountKey string) (requireCaptcha bool, err error) {ipKey := fmt.Sprintf("login:ip:%s", ip)acctKey := fmt.Sprintf("login:acct:%s", accountKey)lockKey := fmt.Sprintf("login:lock:%s", accountKey)locked, err := g.rdb.Exists(ctx, lockKey).Result()if err != nil {return false, err}if locked == 1 {return false, ErrAccountLocked}ipCount, err := g.rdb.Incr(ctx, ipKey).Result()if err != nil {return false, err}if ipCount == 1 {g.rdb.Expire(ctx, ipKey, ipWindow)}if ipCount > ipMaxAttempts {return false, ErrRateLimited}failCount, _ := g.rdb.Get(ctx, acctKey).Int64()return failCount >= captchaThreshold, nil}// RecordResult runs AFTER password verification, whether it succeeded or not.func (g *LoginGuard) RecordResult(ctx context.Context, accountKey string, success bool) error {acctKey := fmt.Sprintf("login:acct:%s", accountKey)lockKey := fmt.Sprintf("login:lock:%s", accountKey)if success {return g.rdb.Del(ctx, acctKey).Err()}failCount, err := g.rdb.Incr(ctx, acctKey).Result()if err != nil {return err}if failCount == 1 {g.rdb.Expire(ctx, acctKey, accountWindow)}if failCount >= accountMaxAttempts {overflow := failCount - accountMaxAttemptsshift := overflowif shift > 5 {shift = 5 // cap the exponent so lockouts don't grow unbounded}lockDuration := lockoutBase * time.Duration(int64(1)<<uint(shift))if lockDuration > maxLockDuration {lockDuration = maxLockDuration}return g.rdb.Set(ctx, lockKey, "1", lockDuration).Err()}return nil}
The two Redis keys — login:ip:* and login:acct:* — expire on their own via EXPIRE/SET ... EX, so there's no cleanup job and no permanent state to accidentally leave an account locked forever. INCR on a non-existent key atomically creates it at 1, which is what makes the "if count == 1, set expiry" pattern safe under concurrent requests — the small race window between INCR and EXPIRE only risks a slightly-too-long window on the rare request that loses the race, never an infinite one.
#Credential stuffing needs one more layer: know your leaked passwords
Rate limiting doesn't stop credential stuffing when each account is only tried once. The layer that actually catches this is checking submitted passwords (or better, submitted email+password pairs) against known-breached credential databases at signup and login time — using a k-anonymity range query (send only the first 5 characters of a SHA-1 hash, get back all suffixes) so you never transmit the actual password to a third party. If a user's password matches a known breach corpus, force a reset regardless of whether this login attempt succeeded — the attacker already has a valid password for it.