Brute Force #

Brute force is an attack that tries every possible combination until finding the right one. In the context of web security, this most often means trying thousands or millions of password combinations for one account, or trying credential lists leaked from previous data breaches. No technical sophistication required — just automation and patience.

What makes brute force relevant and dangerous in 2025: the number of credentials leaked from data breaches has reached billions of pairs. Collections like RockYou2021 contain 8.4 billion unique passwords. Attackers don’t need to guess from scratch — they use credentials leaked from one service to try logging into other services (credential stuffing), assuming many people use the same password across platforms.

Systems without brute force protection are systems waiting to be exploited — not a question of whether, but when.

The Four Types of Brute Force Attacks #

graph TD
    A[Brute Force Attacks] --> B[Credential Stuffing]
    A --> C[Password Spraying]
    A --> D[Dictionary Attack]
    A --> E[Pure Brute Force]

    B --> B1["Use credentials leaked\nfrom other breaches\n1 password per target"]
    C --> C1["1 common password\nacross many accounts\nAvoids lockouts"]
    D --> D1["Common password wordlists\nTop 10M passwords\nMangling rules"]
    E --> E1["All character combinations\na, b, c... aa, ab...\nOnly for short passwords"]

    B1 --> F["Large scale, high accuracy\n0.1-2% success rate"]
    C1 --> G["No lockout triggers\nSlow but persistent"]
    D1 --> H[Effective for weak passwords]
    E1 --> I[Impractical for 12+ characters]

Credential Stuffing #

This is the most serious threat today. Attackers take email:password pairs from existing breaches and automatically try them against hundreds of different services.

Why credential stuffing is so effective:

  Available data:
  → RockYou2021: 8.4 billion credentials
  → Collection #1-5: 2.2 billion unique credentials
  → Breaches from LinkedIn, Adobe, Yahoo, etc. still actively used

  Success rates:
  → 0.1% - 2% of credentials still valid on other services
  → With 10 million credentials: 10,000 - 200,000 accounts successfully compromised

  Tools used:
  → Sentry MBA, OpenBullet, Snipr — freely available automated tools
  → Per-site configuration, proxy rotation support, CAPTCHA bypass
  → Can run from thousands of different IPs simultaneously

  Credential stuffing signs:
  → Login failure rate suddenly rises
  → Successful logins from unusual IPs/locations
  → Many "successful logins" the user doesn't recognize
  → Login traffic from many different IPs

Password Spraying #

Instead of trying many passwords for one account (which would trigger lockouts), password spraying tries one or a few very common passwords across many accounts. This technique is especially effective in enterprise environments.

Password spraying vs traditional brute force:

  Traditional brute force (easily detected):
  Account: [email protected]
  Attempt 1: password123
  Attempt 2: qwerty123
  Attempt 3: ali123
  ... (thousands of attempts → lockout triggered after 5 attempts)

  Password spraying (hard to detect):
  Password: Spring2025!  (meets the policy but is guessable)

  Attempt 1: [email protected] → Spring2025!    (no lock)
  Attempt 2: [email protected] → Spring2025!   (no lock)
  Attempt 3: [email protected] → Spring2025!  (no lock)
  ...thousands of accounts...
  Attempt N: [email protected] → Spring2025!  ← SUCCESS

  Each account is only tried once or twice → no lockout triggers
  Attackers wait a few hours between batches to be safer

Effective Rate Limiting #

Rate limiting is the first defense against all forms of brute force. But naive implementations are easily bypassed.

// Layered rate limiter for login endpoints:
// 1. Per IP — protects against a single aggressive IP
// 2. Per account — protects against credential stuffing from many IPs
// 3. Per IP + account — the most precise combination

type RateLimitResult struct {
	Allowed           bool
	RemainingAttempts int
	ResetAfterSeconds int
	LockoutSeconds    int // -1 when not locked
}

type LoginRateLimiter struct{}

// Configuration: (max_attempts, window_seconds, lockout_seconds)
var ipLimit = [3]int{30, 300, 300}        // 30 per 5 minutes, 5 minute lockout
var accountLimit = [3]int{5, 900, 900}    // 5 per 15 minutes, 15 minute lockout
var globalLimit = [3]int{10000, 60, 0}    // Global: 10k per minute (DDoS guard)

// redisClient is a go-redis client (import "github.com/redis/go-redis/v9")
var redisClient *redis.Client
var ctx = context.Background()

func (l *LoginRateLimiter) checkAndRecord(ip, email string) RateLimitResult {
	// Check for active lockouts
	if res, locked := l.checkLockout(ip, email); locked {
		return res
	}

	// Check the per-IP rate limit
	ipResult := l.checkLimit("rl:ip:"+ip, ipLimit)
	if !ipResult.Allowed {
		l.setLockout("lockout:ip:"+ip, ipLimit[2])
		return ipResult
	}

	// Check the per-account rate limit (email hashed for privacy)
	emailHash := sha256Hex(email)
	accountResult := l.checkLimit("rl:account:"+emailHash, accountLimit)
	if !accountResult.Allowed {
		l.setLockout("lockout:account:"+emailHash, accountLimit[2])
		return accountResult
	}

	return RateLimitResult{
		Allowed:           true,
		RemainingAttempts: min(ipResult.RemainingAttempts, accountResult.RemainingAttempts),
		ResetAfterSeconds: min(ipResult.ResetAfterSeconds, accountResult.ResetAfterSeconds),
		LockoutSeconds:    -1,
	}
}

func (l *LoginRateLimiter) checkLimit(key string, limit [3]int) RateLimitResult {
	now := time.Now().Unix()
	// ZREMRANGEBYSCORE + ZCARD + ZADD + EXPIRE in one pipeline
	pipe := redisClient.TxPipeline()
	pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(now-int64(limit[1]), 10))
	card := pipe.ZCard(ctx, key)
	pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
	pipe.Expire(ctx, key, time.Duration(limit[1])*time.Second)
	_, _ = pipe.Exec(ctx)

	count := int(card.Val())
	return RateLimitResult{
		Allowed:           count < limit[0],
		RemainingAttempts: max(0, limit[0]-count-1),
		ResetAfterSeconds: limit[1],
		LockoutSeconds:    -1,
	}
}

func (l *LoginRateLimiter) checkLockout(ip, email string) (RateLimitResult, bool) {
	emailHash := sha256Hex(email)
	for _, key := range []string{"lockout:ip:" + ip, "lockout:account:" + emailHash} {
		ttl := redisClient.TTL(ctx, key).Val()
		if ttl > 0 {
			return RateLimitResult{
				Allowed:           false,
				RemainingAttempts: 0,
				ResetAfterSeconds: int(ttl.Seconds()),
				LockoutSeconds:    int(ttl.Seconds()),
			}, true
		}
	}
	return RateLimitResult{}, false
}

func (l *LoginRateLimiter) setLockout(key string, seconds int) {
	redisClient.SetEX(ctx, key, time.Duration(seconds)*time.Second, 1)
}

func (l *LoginRateLimiter) resetOnSuccess(ip, email string) {
	emailHash := sha256Hex(email)
	redisClient.Del(ctx, "rl:ip:"+ip, "rl:account:"+emailHash,
		"lockout:ip:"+ip, "lockout:account:"+emailHash)
}

func sha256Hex(email string) string {
	sum := sha256.Sum256([]byte(strings.ToLower(email)))
	return hex.EncodeToString(sum[:])[:16]
}

var loginLimiter = &LoginRateLimiter{}

// Login handler (net/http)
func loginHandler(w http.ResponseWriter, r *http.Request) {
	ip := getRealIP(r) // handle proxy headers carefully
	email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
	password := r.FormValue("password")

	// Check rate limits before anything else
	limitResult := loginLimiter.checkAndRecord(ip, email)

	if !limitResult.Allowed {
		w.Header().Set("Retry-After", strconv.Itoa(limitResult.ResetAfterSeconds))
		w.WriteHeader(http.StatusTooManyRequests)
		_ = json.NewEncoder(w).Encode(map[string]any{"error": "Too many attempts"})
		return
	}

	// Process the login with consistent timing
	user := authenticateUser(email, password)
	if user != nil {
		loginLimiter.resetOnSuccess(ip, email)
		sessionToken := createSession(user.ID, r)
		setAuthCookie(w, sessionToken)
		_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
		return
	}

	// Failed login — the same message for all cases
	w.WriteHeader(http.StatusUnauthorized)
	_ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid email or password"})
}

Timing Attacks: A Hole Often Overlooked #

The time needed to process login requests can leak information to attackers.

// ANTI-PATTERN: timing leak — attackers can distinguish "email not found" vs "wrong password"
func loginWithTimingLeak(w http.ResponseWriter, r *http.Request) {
	email := r.FormValue("email")
	password := r.FormValue("password")

	user := findUserByEmail(email)

	if user == nil {
		// Returns FAST — no hash computation
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
		return
	}

	if !verifyPassword(password, user.PasswordHash) {
		// Returns SLOW — hash computation happens
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
		return
	}
}

// Attackers measure response times:
// < 5ms = email not in the database (immediate return)
// > 100ms = email exists but the password is wrong (hash computation happens)
// → Attackers can enumerate valid emails without triggering rate limits

// CORRECT: consistent response times
// argon2id via golang.org/x/crypto/argon2 — hash computed once at startup
var dummyHash = hashPassword("dummy_password_that_never_matches")

func loginConstantTime(w http.ResponseWriter, r *http.Request) {
	email := r.FormValue("email")
	password := r.FormValue("password")

	user := findUserByEmail(email)

	passwordHash := dummyHash
	if user != nil {
		passwordHash = user.PasswordHash
	}

	// Verification always runs, whether or not the user exists
	valid := verifyPassword(password, passwordHash)
	if user != nil && valid {
		// Login successful
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
		return
	}

	// Failed login — the same message
	writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid email or password"})
}

Account Lockouts That Don’t Hurt Legitimate Users #

Overly aggressive account lockouts can be used by attackers as availability attacks: they deliberately lock legitimate users’ accounts by trying wrong passwords.

// Smart lockout:
// - No immediate permanent lockout
// - Progressive delays
// - Doesn't allow attackers to DoS other users' accounts

type SmartAccountLockout struct{}

// Progressive lockout: more failures = longer locks
var lockoutSchedule = [][2]int{
	{3, 30},     // After 3 failures: lock for 30 seconds
	{5, 300},    // After 5 failures: lock for 5 minutes
	{10, 1800},  // After 10 failures: lock for 30 minutes
	{20, 86400}, // After 20 failures: lock for 24 hours
}

// Return the lockout duration based on the failure count
func (s *SmartAccountLockout) getLockoutDuration(failureCount int) int {
	duration := 0
	for _, step := range lockoutSchedule {
		if failureCount >= step[0] {
			duration = step[1]
		}
	}
	return duration
}

func (s *SmartAccountLockout) recordFailure(accountKey string) map[string]any {
	key := "login_failures:" + accountKey

	// Increment the failure count
	failureCount := redisClient.Incr(ctx, key).Val()

	// Set the expiry if newly created
	if failureCount == 1 {
		redisClient.Expire(ctx, key, 24*time.Hour) // reset after 24 hours without activity
	}

	// Determine the lockout duration
	lockoutDuration := s.getLockoutDuration(int(failureCount))
	if lockoutDuration > 0 {
		lockoutKey := "lockout:" + accountKey
		redisClient.SetEX(ctx, lockoutKey, time.Duration(lockoutDuration)*time.Second, failureCount)
	}

	return map[string]any{
		"failure_count":   failureCount,
		"locked":          lockoutDuration > 0,
		"lockout_seconds": lockoutDuration,
	}
}

// Return (is_locked, seconds_remaining)
func (s *SmartAccountLockout) isLocked(accountKey string) (bool, int) {
	lockoutKey := "lockout:" + accountKey
	ttl := redisClient.TTL(ctx, lockoutKey).Val()
	return ttl > 0, max(0, int(ttl.Seconds()))
}

// Reset after successful logins
func (s *SmartAccountLockout) unlockOnSuccess(accountKey string) {
	redisClient.Del(ctx, "login_failures:"+accountKey, "lockout:"+accountKey)
}
Safe lockout strategies:

  ✓ Progressive lockouts — more failures = longer locks
    Attackers trying 100x get long lockouts
    Users who forgot passwords usually try 2-3x

  ✓ Soft lockouts with email notifications
    "Your account is temporarily locked. If this wasn't you trying to log in,
    click here to secure your account."

  ✓ Unlock via email verification
    Send an unlock link to the registered email
    Attackers can't unlock unless they have email access

  ✗ Don't permanently lock out without unlock mechanisms
    Legitimate users can't access their own accounts

  ✗ Don't lock out based on IPs alone
    One IP can be shared by many users (NAT, corporate networks)
    IP lockouts can hit innocent users

CAPTCHAs: Effective but Not a Complete Solution #

CAPTCHAs make automation harder by presenting challenges that are easy for humans but hard for machines.

// Google reCAPTCHA v3 integration (invisible, score-based)
var recaptchaSecret = os.Getenv("RECAPTCHA_SECRET_KEY")

const recaptchaVerifyURL = "https://www.google.com/recaptcha/api/siteverify"
const recaptchaThreshold = 0.5 // Score 0 (bot) to 1 (human)

// Verify a reCAPTCHA token. Returns (is_human, score)
func verifyRecaptcha(token, action string) (bool, float64) {
	body := url.Values{"secret": {recaptchaSecret}, "response": {token}}
	resp, err := http.PostForm(recaptchaVerifyURL, body)
	if err != nil {
		// If the CAPTCHA service is down, don't block legitimate users
		// But log for monitoring
		log.Println("reCAPTCHA verification failed — service unavailable")
		return true, 1.0 // Default allow if the service is down
	}
	defer resp.Body.Close()

	var result struct {
		Success bool    `json:"success"`
		Action  string  `json:"action"`
		Score   float64 `json:"score"`
	}
	_ = json.NewDecoder(resp.Body).Decode(&result)

	if !result.Success {
		return false, 0.0
	}

	// Verify the matching action
	if result.Action != action {
		return false, 0.0
	}

	score := result.Score
	return score >= recaptchaThreshold, score
}

// Login route (net/http)
func loginHandler(w http.ResponseWriter, r *http.Request) {
	// Get the CAPTCHA token from the request
	captchaToken := r.FormValue("captcha_token")

	if captchaToken == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "CAPTCHA required"})
		return
	}

	isHuman, score := verifyRecaptcha(captchaToken, "login")

	if !isHuman {
		log.Printf("Low reCAPTCHA score on login: score=%v ip=%v", score, r.RemoteAddr)
		writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "CAPTCHA verification failed"})
		return
	}

	// Continue the login process
	// ...
}
CAPTCHA limitations:

  reCAPTCHA v2 (image puzzles):
  → Solvable by cheaply paid humans (CAPTCHA farms)
  → Price: $1 per 1000 solves — attackers can bypass cheaply

  reCAPTCHA v3 (invisible, score-based):
  → Doesn't disrupt UX
  → Bypassable by using real browsers
  → Still effective for detecting simple bots

  hCaptcha, Turnstile (Cloudflare):
  → Increasingly popular alternatives
  → Different privacy trade-offs than Google

  CAPTCHAs aren't standalone solutions:
  → Use alongside rate limiting and account lockouts
  → CAPTCHAs complement, not replace

Credential Stuffing Detection #

Credential stuffing traffic patterns differ from normal logins — detectable with proper analysis.

// Detect credential stuffing based on anomalous traffic patterns
type CredentialStuffingDetector struct {
	window time.Duration
}

// GeoIP database for location detection (oschwald/geoip2-golang)
var geoReader *geoip2.Reader

func newCredentialStuffingDetector(window time.Duration) *CredentialStuffingDetector {
	return &CredentialStuffingDetector{window: window}
}

// Analyze whether this request looks like credential stuffing
func (d *CredentialStuffingDetector) analyzeLoginPattern(r *http.Request, email string) map[string]any {
	signals := []string{}
	ip := clientIP(r)

	// Signal 1: unusual User-Agents or identical ones across requests
	userAgent := r.UserAgent()
	if d.isSuspiciousUserAgent(userAgent) {
		signals = append(signals, "suspicious_user_agent")
	}

	// Signal 2: missing headers real browsers usually have
	expectedHeaders := []string{"Accept", "Accept-Language", "Accept-Encoding"}
	missing := 0
	for _, h := range expectedHeaders {
		if r.Header.Get(h) == "" {
			missing++
		}
	}
	if missing > 1 {
		signals = append(signals, "missing_browser_headers")
	}

	// Signal 3: many different emails from one IP in a short time
	ipKey := "logins_from_ip:" + ip
	emailsFromIP := redisClient.PFCount(ctx, ipKey).Val() // HyperLogLog for counting
	if emailsFromIP > 10 {
		signals = append(signals, "many_accounts_from_ip")
	}

	// Signal 4: locations inconsistent with historical login patterns
	if city, err := geoReader.City(net.ParseIP(ip)); err == nil {
		country := city.Country.IsoCode
		if d.isImpossibleLocation(email, country) {
			signals = append(signals, "impossible_location")
		}
	}

	// Signal 5: IP addresses from known proxy/VPN/Tor lists
	if d.isKnownProxyIP(ip) {
		signals = append(signals, "proxy_ip")
	}

	// Record this email from the IP (with a TTL)
	redisClient.PFAdd(ctx, ipKey, email)
	redisClient.Expire(ctx, ipKey, d.window)

	riskScore := len(signals)
	return map[string]any{
		"risk_score": riskScore,
		"signals":    signals,
		"action":     d.determineAction(riskScore),
	}
}

func (d *CredentialStuffingDetector) isSuspiciousUserAgent(ua string) bool {
	// Credential stuffing tools often use strange UAs
	suspiciousPatterns := []string{
		"python-requests", "axios/", "okhttp/", "curl/",
		"go-http-client", "java/", "php/",
	}
	uaLower := strings.ToLower(ua)
	for _, p := range suspiciousPatterns {
		if strings.Contains(uaLower, p) {
			return true
		}
	}
	return false
}

func (d *CredentialStuffingDetector) determineAction(riskScore int) string {
	switch {
	case riskScore == 0:
		return "allow"
	case riskScore == 1:
		return "monitor" // log but allow
	case riskScore == 2:
		return "challenge" // show a CAPTCHA
	default:
		return "block" // temporarily block
	}
}

func (d *CredentialStuffingDetector) isKnownProxyIP(ip string) bool {
	// Check against known proxy/VPN/Tor IP databases
	// Can use services like IPQualityScore, MaxMind, etc.
	proxyKey := "known_proxy:" + ip
	return redisClient.Exists(ctx, proxyKey).Val() == 1
}

func (d *CredentialStuffingDetector) isImpossibleLocation(email, currentCountry string) bool {
	// Compare with the last known country
	historyKey := "login_country:" + sha256Hex(email)
	lastCountry := redisClient.Get(ctx, historyKey).Val()
	if lastCountry != "" && lastCountry != currentCountry {
		return true
	}
	return false
}

var detector = newCredentialStuffingDetector(5 * time.Minute)

// Login route (net/http)
func loginHandler(w http.ResponseWriter, r *http.Request) {
	ip := clientIP(r)
	email := r.FormValue("email")

	// Analyze credential stuffing signals
	analysis := detector.analyzeLoginPattern(r, email)

	if analysis["action"] == "block" {
		writeJSON(w, http.StatusTooManyRequests, map[string]any{
			"error":       "Suspicious activity detected",
			"retry_after": 300,
		})
		return
	}

	if analysis["action"] == "challenge" {
		// Request a CAPTCHA for suspicious requests
		writeJSON(w, http.StatusAccepted, map[string]any{
			"challenge_required": true,
			"challenge_type":     "captcha",
		})
		return
	}

	// Continue the normal login process
	// ...
}

Breached Password Detection #

Rejecting already-known breached passwords is a very effective proactive step.

// Rejecting already-known breached passwords is a very effective proactive step
const hibpAPIURL = "https://api.pwnedpasswords.com/range/%s"

// Check whether the password exists in the HaveIBeenPwned database.
// Uses k-Anonymity — the password is never sent to the API.
// Returns (is_breached, breach_count)
func isPasswordBreached(password string) (bool, int) {
	sha1 := sha1Hex(password)
	prefix, suffix := sha1[:5], sha1[5:]

	resp, err := http.Get(fmt.Sprintf(hibpAPIURL, prefix))
	if err != nil {
		// If the API is unavailable, don't block — log and continue
		log.Println("HIBP API unavailable")
		return false, 0
	}
	defer resp.Body.Close()

	scanner := bufio.NewScanner(resp.Body)
	for scanner.Scan() {
		parts := strings.Split(scanner.Text(), ":")
		if parts[0] == suffix {
			count, _ := strconv.Atoi(parts[1])
			return true, count
		}
	}
	return false, 0
}

func sha1Hex(s string) string {
	sum := sha1.Sum([]byte(s))
	return strings.ToUpper(hex.EncodeToString(sum[:]))
}

// Use at registration and password changes
func validatePasswordStrength(password string) []string {
	errors := []string{}

	if len(password) < 12 {
		errors = append(errors, "Password must be at least 12 characters")
	}

	isBreached, count := isPasswordBreached(password)
	if isBreached {
		errors = append(errors, fmt.Sprintf(
			"This password was found in %d data breaches. "+
				"Use a unique password that has never been used before.", count))
	}

	return errors
}

Monitoring and Alerting #

Undetected brute force can run for days without anyone knowing.

// Alert metrics to monitor

// 1. Login failure rate — significant rises = attack signs
// Baseline: X failures per minute during normal hours
// Alert: exceeding 5x the baseline for 5 consecutive minutes

// 2. Unique email count from one IP within 5 minutes
// Baseline: 1-2 emails per IP
// Alert: > 10 different emails from one IP

// 3. Login success rate — significant drops = attack signs
// Baseline: 70-80% of login requests succeed
// Alert: success rate falling below 30% for 10 minutes

// 4. Accounts successfully logging in from new locations
// Alert: users who usually log in from Jakarta
//        suddenly logging in from an Eastern European IP

// Simple implementation with Redis counters
func recordLoginMetric(success bool, ip, email string) {
	now := time.Now().Unix()
	minuteBucket := now - (now % 60)

	pipe := redisClient.TxPipeline()
	if success {
		pipe.Incr(ctx, fmt.Sprintf("login:success:%d", minuteBucket))
	} else {
		pipe.Incr(ctx, fmt.Sprintf("login:failure:%d", minuteBucket))
		// Track unique emails per IP
		pipe.SAdd(ctx, fmt.Sprintf("login:ips:%s:%d", ip, minuteBucket), email)
		pipe.Expire(ctx, fmt.Sprintf("login:ips:%s:%d", ip, minuteBucket), 600*time.Second)
	}
	pipe.Expire(ctx, fmt.Sprintf("login:success:%d", minuteBucket), 600*time.Second)
	pipe.Expire(ctx, fmt.Sprintf("login:failure:%d", minuteBucket), 600*time.Second)
	_, _ = pipe.Exec(ctx)
}

func checkBruteForceAlert() []string {
	now := time.Now().Unix()
	alerts := []string{}

	// Check the last 5 minutes
	totalSuccess := 0
	totalFailure := 0
	for i := 0; i < 5; i++ {
		bucket := now - (now % 60) - int64(i*60)
		success, _ := redisClient.Get(ctx, fmt.Sprintf("login:success:%d", bucket)).Int()
		failure, _ := redisClient.Get(ctx, fmt.Sprintf("login:failure:%d", bucket)).Int()
		totalSuccess += success
		totalFailure += failure
	}

	total := totalSuccess + totalFailure
	if total > 0 {
		failureRate := float64(totalFailure) / float64(total)
		if failureRate > 0.7 && total > 100 {
			alerts = append(alerts, fmt.Sprintf(
				"High login failure rate: %.1f%% (%d/%d failed in last 5 min)",
				failureRate*100, totalFailure, total))
		}
	}

	return alerts
}

Anti-Patterns to Avoid #

// ✗ Anti-pattern 1: no rate limiting on login endpoints
func loginHandler(w http.ResponseWriter, r *http.Request) {
	user := authenticate(r.FormValue("email"), r.FormValue("password"))
	// No rate limit — millions of passwords can be tried per second
}

// ✗ Anti-pattern 2: error messages distinguishing cases
func distinguishErrors(userExists, wrongPassword bool) {
	if !userExists {
		// returns "Email not registered" — leaks: the email doesn't exist
	}
	if wrongPassword {
		// returns "Wrong password" — leaks: the email is valid
	}
	// ✓ Solution: "Invalid email or password" for all cases
}

// ✗ Anti-pattern 3: lockouts abusable to DoS other people's accounts
// Attackers deliberately try 10x with a target email
// → The target account gets locked
// ✓ Solution: progressive delays + email notifications, not permanent hard lockouts

// ✗ Anti-pattern 4: rate limits only per IP
// Attackers use thousands of different IPs (botnets, residential proxies)
// → Each IP only does 1-2 attempts → never triggers the IP limit
// ✓ Solution: rate limit per account TOO, not just per IP

// ✗ Anti-pattern 5: no monitoring
// Credential stuffing runs for days undetected
// ✓ Solution: alerts for login failure rate anomalies

// ✗ Anti-pattern 6: resetting rate limits after successful logins from the same IP
// Attackers successfully log into one account → the rate limit resets for that IP
// → Can continue trying other accounts from the same IP without limits
// ✓ Solution: only reset for the successful account, not the IP

Brute Force Protection Checklist #

RATE LIMITING:
  □ Per-IP rate limits (looser — NAT sharing possible)
  □ Per-account/email rate limits (stricter)
  □ Global login endpoint rate limits (DDoS guard)
  □ Progressive: more failures = longer waits
  □ Retry-After headers sent on 429 responses

ACCOUNT LOCKOUT:
  □ Progressive lockouts (not immediately permanent)
  □ Email notifications when accounts lock
  □ Unlock mechanisms via email (not just waiting out timeouts)
  □ Attackers can't trivially lock other people's accounts

TIMING & INFORMATION:
  □ Consistent response times: email missing ≈ wrong password
  □ Error messages don't distinguish "email not found" vs "wrong password"
  □ Dummy hash computations when emails aren't found

CREDENTIAL STUFFING:
  □ HIBP API checks at registration and password changes
  □ Suspicious User-Agent detection (python-requests, curl, etc.)
  □ Many-emails-from-one-IP detection within short windows
  □ Login detection from unusual account locations

CAPTCHA:
  □ CAPTCHAs or challenges after N failures
  □ CAPTCHAs don't block users when the CAPTCHA service is down
  □ CAPTCHAs aren't the only protection (combined with rate limiting)

MFA:
  □ MFA available and encouraged for all users
  □ MFA mandatory for admin and high-privilege accounts
  □ MFA code brute force also rate-limited

MONITORING:
  □ Alerts for anomalous login failure rates
  □ Alerts for many different emails from one IP
  □ Alerts for successful logins from highly unusual locations
  □ Real-time dashboards for login metrics
  □ All failed attempts logged with details (IP, UA, timestamp, email hash)

Summary #

  • Credential stuffing is the biggest threat — not guessing random passwords, but using billions of already-leaked credentials. Success rates are small but volumes are enormous.
  • Rate limiting must happen in two dimensions — per-IP alone isn’t enough because credential stuffing uses thousands of IPs. Per-account rate limits are the more important protection.
  • Progressive lockouts are better than hard lockouts — hard lockouts can be abused to lock other people’s accounts. Progressive delays (30 seconds, 5 minutes, 30 minutes) provide protection without becoming a DoS weapon.
  • Timing attacks leak information — time differences between “email not found” and “wrong password” enable email enumeration. Always run hash computations even when emails aren’t found.
  • Uniform error messages are mandatory — “Invalid email or password” for all cases. Never distinguish “email not registered” from “wrong password”.
  • Proactive HIBP checks prevent breached credential reuse — rejecting passwords found in breach databases is more effective than all other complexity policies.
  • MFA is the most effective mitigation — even if passwords are guessed, attackers still need a second factor. Credential stuffing is almost entirely ineffective against MFA-protected accounts.
  • Detect credential stuffing patterns — many different emails from one IP, unusual User-Agents, missing browser headers, and impossible locations are detectable signals.
  • CAPTCHAs aren’t standalone solutions — CAPTCHA farms solve them for $1/1000. Use them as one layer, not the only one.
  • Real-time monitoring is the key — brute force running undetected for days can compromise thousands of accounts before anyone notices.
#

← Previous: DDoS   Next: Encryption

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact