Testing Rate Limiting and Lockout
Testing defenses under abuse
Rate limiting and lockout are defenses that only activate under abuse. If you do not test them with actual abuse patterns, you do not know they work.
Rate limit tests
describe("rate limiting", () => {
it("allows requests under the limit", async () => {
// Per-email limit is 5 per minute
for (let i = 0; i < 5; i++) {
const res = await login("[email protected]", "wrong");
assert.strictEqual(res.status, 401, `Request ${i + 1} should get 401, not 429`);
}
});
it("blocks requests over the limit with 429", async () => {
// Send 6 requests (5 allowed + 1 over)
for (let i = 0; i < 5; i++) {
await login("[email protected]", "wrong");
}
const res = await login("[email protected]", "wrong");
assert.strictEqual(res.status, 429);
});
it("includes Retry-After header on 429", async () => {
// Exhaust the limit
for (let i = 0; i < 6; i++) {
await login("[email protected]", "wrong");
}
const res = await login("[email protected]", "wrong");
const retryAfter = res.headers.get("retry-after");
assert.ok(retryAfter, "429 response must include Retry-After header");
assert.ok(parseInt(retryAfter!) > 0, "Retry-After must be a positive number");
});
it("same error message for IP and email rate limits", async () => {
// Exhaust email limit
for (let i = 0; i < 6; i++) {
await login("[email protected]", "wrong");
}
const emailLimited = await login("[email protected]", "wrong");
const emailBody = await emailLimited.json();
// Both should use the same generic message
assert.ok(
emailBody.error.includes("Too many") || emailBody.error.includes("try again"),
"Error should not reveal which limit was hit",
);
});
}); The last test verifies that the error message does not distinguish between IP and email rate limiting. If it did, an attacker could learn which limit they hit and adjust their strategy.
Lockout tests
describe("account lockout", () => {
it("locks account after 10 failed attempts", async () => {
const email = "[email protected]";
// Create this user in test seed
for (let i = 0; i < 10; i++) {
await login(email, "wrong");
}
const res = await login(email, "wrong");
assert.strictEqual(res.status, 429);
const body = await res.json();
assert.ok(body.error.includes("locked") || body.error.includes("Too many"));
});
it("correct password fails during lockout", async () => {
const email = "[email protected]";
// Create this user with known password
// Trigger lockout
for (let i = 0; i < 10; i++) {
await login(email, "wrong");
}
// Even the correct password should fail
const res = await login(email, "password123");
assert.strictEqual(res.status, 429, "Correct password must fail during lockout");
});
it("successful login clears failed attempt counter", async () => {
const email = "[email protected]";
// 9 failed attempts (one below lockout threshold)
for (let i = 0; i < 9; i++) {
await login(email, "wrong");
}
// Successful login should clear the counter
await login(email, "password123");
// One more failure should not trigger lockout (counter was reset)
const res = await login(email, "wrong");
assert.strictEqual(res.status, 401, "Counter should have been cleared by successful login");
});
}); The “correct password fails during lockout” test is important. Without it, an attacker who guesses the correct password during lockout could still log in, defeating the lockout mechanism.
The “successful login clears counter” test verifies that legitimate users who occasionally mistype their password do not accumulate failures toward lockout.
Exercises
Exercise 1: Write the rate limit tests. You will need unique email addresses per test to avoid interfering with each other’s rate limit counters.
Exercise 2: Write the lockout tests. Verify that lockout engages at exactly the threshold (10 attempts), not before.
Exercise 3: Add a test that verifies lockout expires after the configured duration. Temporarily set the lockout duration to 1 second, wait 2 seconds, and verify login works again.
Why should the correct password fail during lockout?