Account Lockout
Why lockout on top of rate limiting
Rate limiting caps how fast attempts arrive. Account lockout caps how many attempts succeed against a single account, ever.
Consider: the per-email rate limit allows 5 attempts per minute. An attacker who is patient can try 5 passwords per minute, 300 per hour, 7,200 per day. That is enough to try every common password.
Account lockout says: after 10 failed attempts total, the account is locked for 15 minutes. Even at 5 per minute, the attacker gets 10 tries, then waits 15 minutes, gets 10 more, and so on. This drops the throughput from 7,200/day to under 1,000/day.
The lockout store
Create src/lockout.ts:
// src/lockout.ts
interface LockoutEntry {
failedAttempts: number;
lockedUntil: number | null;
}
const store = new Map<string, LockoutEntry>();
const MAX_ATTEMPTS = 10;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
export function recordFailedAttempt(email: string): void {
const entry = store.get(email) ?? { failedAttempts: 0, lockedUntil: null };
entry.failedAttempts++;
if (entry.failedAttempts >= MAX_ATTEMPTS) {
entry.lockedUntil = Date.now() + LOCKOUT_DURATION;
}
store.set(email, entry);
}
export function isLocked(email: string): { locked: boolean; retryAfterMs: number } {
const entry = store.get(email);
if (!entry || !entry.lockedUntil) {
return { locked: false, retryAfterMs: 0 };
}
const now = Date.now();
if (now > entry.lockedUntil) {
// Lockout expired — reset
store.delete(email);
return { locked: false, retryAfterMs: 0 };
}
return { locked: true, retryAfterMs: entry.lockedUntil - now };
}
export function clearFailedAttempts(email: string): void {
store.delete(email);
} Three functions:
recordFailedAttempt increments the failure count and triggers lockout after MAX_ATTEMPTS.
isLocked checks if an account is currently locked. If the lockout has expired, it clears the record and returns unlocked.
clearFailedAttempts resets the counter. Call this after a successful login so legitimate users do not accumulate failed attempts from typos.
Wire it into the login route
Update the login handler in src/routes/auth.ts:
import { isLocked, recordFailedAttempt, clearFailedAttempts } from "../lockout.js";
route.post("/login", {
request: { body: LoginBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { email, password } = c.input.body;
const ip = getClientIp(c.request);
// Rate limiting (from previous lesson) ...
// Check lockout
const lockout = isLocked(email);
if (lockout.locked) {
log("login_locked", { email, ip });
return Response.json(
{ error: "Account temporarily locked. Try again later." },
{
status: 429,
headers: {
"retry-after": String(Math.ceil(lockout.retryAfterMs / 1000)),
},
},
);
}
// Look up user
const user = findByEmail(email);
if (!user) {
recordFailedAttempt(email);
log("login_failed", { email, ip, reason: "user_not_found" });
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
// Verify password
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
recordFailedAttempt(email);
log("login_failed", { email, ip, reason: "wrong_password" });
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
// Success — clear failed attempts
clearFailedAttempts(email);
log("login_success", { email, ip, userId: user.id });
// Create session...
},
}); The flow:
- Check rate limits (previous lesson)
- Check if the account is locked
- Look up the user and verify the password
- On failure: record the failed attempt (which may trigger lockout)
- On success: clear the failed attempt counter
Temporary vs. permanent lockout
We use temporary lockout (15 minutes). After the lockout period, the account unlocks automatically and the counter resets.
Permanent lockout (requiring admin or email verification to unlock) is stronger but creates support burden and can be abused. An attacker could intentionally lock out a user by sending failed login attempts with the user’s email. Temporary lockout is the better default.
Should lockout use the same error message?
The lockout response says “Account temporarily locked” while failed login says “Invalid email or password.” This is a tradeoff.
Saying “account locked” does reveal that the email is registered (an account must exist to be locked). But the lockout state is temporary and only occurs after many failures, making it impractical for email enumeration at scale. The usability benefit (the user knows to wait) outweighs the information leak in most applications.
If you want maximum opacity, you can return “Invalid email or password” for lockout too. The Retry-After header still tells the client when to try again.
Exercises
Exercise 1: Send 10 failed login attempts for the same email. Verify the 10th triggers lockout. Then wait (or temporarily set LOCKOUT_DURATION to 5 seconds) and verify the account unlocks.
Exercise 2: Send 9 failed attempts, then 1 successful login. Verify the counter is cleared. Send another failed attempt and verify it starts counting from 1, not from 10.
Exercise 3: What happens if an attacker intentionally locks out another user’s account? They can. This is a denial-of-service attack against a specific user. Consider how you might mitigate this (e.g., requiring CAPTCHA after the 5th failure instead of locking the account).
Why do we clear the failed attempt counter after a successful login?
Why is temporary lockout preferred over permanent lockout?