hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Securing Your API with @hectoday/http

The Threat Landscape

  • What Could Go Wrong
  • Project Setup

Brute-Force Protection

  • Rate Limiting Login Attempts
  • Account Lockout
  • Timing Attack Prevention

CSRF Protection

  • What Is CSRF?
  • CSRF Tokens
  • CSRF for API Consumers

Token Hardening

  • Refresh Token Rotation
  • Token Revocation
  • Secure Token Storage

Password Reset

  • The Password Reset Flow
  • Building the Reset Routes
  • Reset Security

Putting It All Together

  • Security Headers
  • Logging and Monitoring
  • Security Checklist
  • Capstone: Hardened Auth API

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:

  1. Check rate limits (previous lesson)
  2. Check if the account is locked
  3. Look up the user and verify the password
  4. On failure: record the failed attempt (which may trigger lockout)
  5. 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?

← Rate Limiting Login Attempts Timing Attack Prevention →

© 2026 hectoday. All rights reserved.