Protecting Login Endpoints: Credential-Stuffing Signals & Adaptive Rate Limits
The first credential-stuffing wave I handled on a client SaaS did not trip a single alert. Our only rule was "5 failed logins per IP per minute", and the traffic came from a few thousand residential IPs, each trying two
The first credential-stuffing wave I handled on a client SaaS did not trip a single alert. Our only rule was "5 failed logins per IP per minute", and the traffic came from a few thousand residential IPs, each trying two or three email and password pairs and then moving on. What finally caught it was a dashboard someone glanced at by chance: login failures had jumped from about 4% of attempts to over 60% overnight, while successful logins stayed flat. By then a few dozen accounts with reused passwords had been taken over. Per-IP limits alone were never going to stop that.
I wrote this with help from AI tools and checked every snippet against current OWASP Credential Stuffing Prevention and Authentication cheat sheets, NIST SP 800-63B, Laravel 11 and Node 22. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the free developer tools at PixelValut. Everything here is about defending a login endpoint you own.
๐ Series Navigation โ Passkeys & Session Hardening for Web Apps
- Part 1: Rolling Out Passkeys Without Locking Users Out
- Part 2: Session Lifecycle Hardening: Rotation, Timeouts & Server-Side Revocation
- Part 3: CSRF in 2026: SameSite, Fetch Metadata & Origin Checks
- Part 4: Account Recovery Hardening: The Side Door Next to Your Passkey
- Part 5 (This Post): Protecting Login Endpoints: Credential-Stuffing Signals & Adaptive Rate Limits
- Part 6: Step-Up Authentication for Sensitive Actions
Related earlier posts: Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls ยท OAuth 2.1, OIDC ID Tokens & PKCE Forensics
How do you protect a login endpoint from credential stuffing?
Direct answer: Layer your defences. Rate-limit per account and per IP, but alert on the global failure ratio, because stuffing spreads across many IPs. Reject known-breached passwords, return identical responses for unknown users and wrong passwords, add a challenge or delay only when risk rises, notify users of new-device logins, and push users toward passkeys, which make stolen passwords useless.
Why per-IP limits are not enough
Brute force and credential stuffing look similar in logs but behave very differently:
| Attack | What it tries | Typical pattern | Control that works |
|---|---|---|---|
| Brute force | Many passwords for one account | High attempts per account | Per-account throttle with backoff |
| Password spraying | One common password across many accounts | Low per-account, high per-password | Breached/common password rejection, per-password-hash counters |
| Credential stuffing | Leaked email+password pairs | 1โ3 attempts per account, spread over thousands of IPs | Global failure-ratio alerts, risk-based challenges, breached-password checks |
| Account enumeration | Which emails exist | Different responses or timing | Uniform responses and timing |
A good login defence watches all four signals at once. Any single limit is easy to stay under.
Layer 1: per-account and per-IP throttling with backoff
In Laravel 11 I define a named limiter that keys on both the normalised email and the IP:
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;
RateLimiter::for('login', function (Request $request) {
$email = mb_strtolower(trim((string) $request->input('email')));
return [
Limit::perMinute(5)->by('acct:'.sha1($email)),
Limit::perMinute(20)->by('ip:'.$request->ip()),
Limit::perHour(50)->by('acct-hour:'.sha1($email)),
];
});
// routes/web.php
Route::post('/login', LoginController::class)->middleware('throttle:login');
Keying by a hash of the email avoids storing addresses in your cache keys. The hourly limit catches slow attackers who stay under the per-minute rule. Avoid hard lockouts that last hours: they let anyone lock a victim out on purpose. Short, growing delays are safer.
Layer 2: alert on the failure ratio, not just counts
This is the signal that would have caught my incident. Count attempts and failures in short buckets and alert when the ratio departs from your normal baseline.
// Node 22 + Redis (ioredis): record each login outcome
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
export async function recordLogin(success) {
const bucket = Math.floor(Date.now() / 60000); // 1-minute bucket
const key = `login:stats:${bucket}`;
await redis.multi()
.hincrby(key, "total", 1)
.hincrby(key, success ? "ok" : "fail", 1)
.expire(key, 3600)
.exec();
}
export async function failureRatio(minutes = 5) {
const now = Math.floor(Date.now() / 60000);
let total = 0, fail = 0;
for (let i = 0; i < minutes; i++) {
const s = await redis.hgetall(`login:stats:${now - i}`);
total += Number(s.total || 0);
fail += Number(s.fail || 0);
}
return total < 50 ? 0 : fail / total; // ignore tiny samples
}
A cron or worker checks failureRatio() every minute and pages when it exceeds, say, three times your normal rate. Your baseline will differ; measure a quiet week first. Also track failures where the email does not exist at all: a spike there is a strong stuffing signal, because leaked lists contain many addresses that never registered with you.
Layer 3: reject breached and common passwords
NIST SP 800-63B recommends checking new passwords against lists of known-compromised values. The Have I Been Pwned range API uses k-anonymity, so only the first five characters of the SHA-1 hash leave your server:
use Illuminate\Support\Facades\Http;
function isBreached(string $password): bool {
$hash = strtoupper(sha1($password));
[$prefix, $suffix] = [substr($hash, 0, 5), substr($hash, 5)];
$body = Http::withHeaders(['Add-Padding' => 'true'])
->timeout(3)
->get("https://api.pwnedpasswords.com/range/{$prefix}")
->body();
foreach (explode("\n", $body) as $line) {
[$s, $count] = array_pad(explode(':', trim($line)), 2, '0');
if ($s === $suffix && (int) $count > 0) return true;
}
return false;
}
Laravel also ships Password::defaults()->uncompromised(), which uses the same API, for registration and password changes. To sanity-check hashing in tests, the PixelValut Hash Generator is handy for confirming a SHA-1 prefix by hand.
Layer 4: uniform responses and timing
Return the same message and status for "no such user" and "wrong password", and spend similar time on both by running a dummy hash check when the user is missing:
$user = User::where('email', $email)->first();
$hash = $user?->password ?? config('auth.dummy_hash'); // a pre-made bcrypt/argon2 hash
$ok = Hash::check($password, $hash) && $user !== null;
if (! $ok) {
return back()->withErrors(['email' => 'Those credentials do not match our records.']);
}
Then confirm the status codes your endpoint actually returns, including the 429 from the throttle and its Retry-After header, with the HTTP Status Checker. If your logs mix formats, the Regex Tester helps build a pattern that pulls failed-login lines out reliably.
Layer 5: challenge only when risk is high
Showing a CAPTCHA to everyone hurts real users and only slows attackers a little. Raise friction when signals stack up: failure ratio above baseline, a new device for that account, an IP with many distinct accounts in the last hour, or a known datacenter range. Options in order of user cost: a short server-side delay, a privacy-friendly challenge (Turnstile, hCaptcha), an emailed one-time code, then blocking. Log which step fired, so you can tune it.
Layer 6: make the password matter less
The real long-term fix is the one Part 1 covered: passkeys. A stolen password is useless against an account that signs in with a passkey only. Until then, send "new sign-in" emails with a one-click "this wasn't me" link that revokes sessions using the server-side revocation from Part 2.
Diagnostic checklist
- [ ] Throttle keyed by account and IP, with an hourly ceiling
- [ ] No long hard lockouts that attackers can trigger on purpose
- [ ] Global failure-ratio metric with a measured baseline and alert
- [ ] Separate counter for attempts against non-existent emails
- [ ] Breached-password check on registration and password change
- [ ] Identical message, status and similar timing for unknown user vs wrong password
- [ ]
429responses includeRetry-After - [ ] Risk-based challenge, not a CAPTCHA for everyone
- [ ] New-device login notifications with session revocation
- [ ] Passkey adoption tracked as a security metric
FAQs
Is a CAPTCHA on every login a good idea?
Usually not. It adds friction for every real user and modern solving services get past many challenges. Use it as one step in a risk-based ladder instead.
Should I lock accounts after five failed attempts?
Prefer growing delays over long lockouts. Hard lockouts let anyone deny service to a known user by typing their email five times.
Does a WAF solve this?
A WAF or bot-management product helps a lot at the edge, but it does not know your account-level signals. Keep the application-level limits and the failure-ratio alert either way.
Is sending password hash prefixes to HIBP safe?
The range API only receives the first five hex characters of a SHA-1 hash, which matches hundreds of entries. The full password and full hash never leave your server.
How do I handle shared office or mobile-carrier IPs?
That is why per-account limits matter more than per-IP ones. Keep the IP limit loose enough for a busy office, and rely on account and ratio signals for precision.
What should I log for login attempts?
Timestamp, hashed account identifier, IP, user agent, outcome and which defence fired. Never log the submitted password, even on failure.
Next in this series, Part 6 covers step-up authentication for sensitive actions.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.