A brute-force attack points an automated tool at your login page and tries password after password against an account until one works. The single most effective defence is rate limiting: after a handful of failed attempts, the server returns HTTP 429 Too Many Requests and refuses to keep checking, which makes the attack far too slow to succeed. This guide walks an owner through hardening a login page, then gives developers the exact framework syntax.
For the site owner (plain English)
Imagine a burglar trying every key on a giant keyring against your front door, one after another, all night. That is a brute-force attack: a program tries millions of passwords against a login until it finds the right one. The defence is not a stronger door — it is a lock that jams after a few wrong keys and refuses to open for a while. On a website that means counting failed logins and, after five to ten, telling the source to slow down and go away. A real customer who mistypes their password twice is unaffected; a bot trying thousands a second is stopped cold. You do not need to write the code to know whether the lock exists: test your login page, and if nothing pushes back after repeated failures, forward the developer section below to whoever maintains the site.
How a brute-force attack works
The attacker picks a target account and a list — a dictionary of common passwords, leaked password lists, or generated combinations — and a tool submits each one to the login endpoint as fast as the server will answer. Because the whole thing is automated, speed is everything: the more guesses per second the server accepts, the sooner the attacker wins. Anything that slows the guessing rate, from a per-account limit to a CAPTCHA, attacks the economics directly.
Brute force targets one account with many passwords. Its cousin, credential stuffing, does the opposite — many accounts, one stolen password each — and needs a slightly different defence, covered in the credential-stuffing prevention guide.
The defences, in order
- Rate-limit failed attempts. Return 429 after 5–10 failures per account or per IP, with a
Retry-Aftercool-down that grows on repeat abuse. - Add a CAPTCHA after a few failures. A Turnstile or hCaptcha challenge after the second or third miss breaks automation while letting a real user through.
- Require multi-factor authentication. Even a correctly guessed password does not grant a session without the second factor — the strongest backstop.
- Never reveal which field was wrong. Return the same generic “invalid username or password” whether the username exists or not, so the attacker cannot enumerate valid accounts.
- Throttle at the edge. A WAF (Cloudflare, AWS WAF) absorbs high-volume bursts before they reach your app.
Developer: exact syntax
Enforce the limit on the endpoint that actually checks the password — the API route, not only the HTML form — so a bot posting straight to the JSON endpoint cannot bypass it.
Node / Express — express-rate-limit
import rateLimit from "express-rate-limit";
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 10, // per IP per window
standardHeaders: true, // includes Retry-After
legacyHeaders: false,
});
app.post("/api/auth/login", loginLimiter, loginHandler);Django — django-axes
# settings.py
INSTALLED_APPS += ["axes"]
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 1 # hours
AXES_LOCKOUT_PARAMETERS = ["ip_address", "username"]Java / Spring Security — attempt tracking
@Component
public class LoginAttemptService {
private final LoadingCache<String, Integer> attempts =
CacheBuilder.newBuilder()
.expireAfterWrite(15, TimeUnit.MINUTES)
.build(CacheLoader.from(k -> 0));
public void loginFailed(String key) { attempts.put(key, attempts.getUnchecked(key) + 1); }
public boolean isBlocked(String key) { return attempts.getUnchecked(key) >= 10; }
}
// In a filter: if isBlocked(ip) -> response.setStatus(429) + Retry-After.Ruby / Rack::Attack
# config/initializers/rack_attack.rb
Rack::Attack.throttle("logins/ip", limit: 10, period: 15.minutes) do |req|
req.ip if req.path == "/login" && req.post?
end
# Rack::Attack returns 429 automatically when the throttle trips.Raw HTTP — the response to return
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{"error": "too_many_requests", "retry_after": 60}For what the 429 status and Retry-After header mean in detail, see the HTTP 429 guide. The OWASP Authentication Cheat Sheet is the reference for the wider set of login protections.
Test whether your login page pushes back on repeated failures — free, no signup.
Run the login rate limit checker →This is one part of the bigger picture — see the pillar, how to rate-limit your login page, which ties brute force, credential stuffing, and the 429 signal together.