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

Timing Attack Prevention

The timing leak

In the previous two lessons, we added rate limiting and lockout checks at the top of the login handler. Those run before the password check. Now look at the password-check part of the login flow — the code that runs after rate limiting and lockout pass:

const user = findByEmail(email);
if (!user) {
  return Response.json({ error: "Invalid email or password" }, { status: 401 });
}

const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
  return Response.json({ error: "Invalid email or password" }, { status: 401 });
}

When the user does not exist, the handler returns immediately. When the user exists but the password is wrong, the handler runs bcrypt.compare, which takes roughly 100ms.

An attacker can measure response times. If a request takes 5ms, the user does not exist. If it takes 100ms, the user exists (and the password was checked). The error message is the same, but the timing is different.

This is a timing-based enumeration attack. The attacker discovers which emails are registered without needing to guess any passwords.

The fix: always hash

When the user is not found, run bcrypt anyway with a dummy hash. This makes both paths take the same amount of time:

// A pre-computed bcrypt hash to use when the user does not exist.
// The actual password does not matter — we just need bcrypt to run.
const DUMMY_HASH = "$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWX";

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 and lockout checks...

    const user = findByEmail(email);

    // Always run bcrypt.compare, even if the user does not exist
    const hash = user?.passwordHash ?? DUMMY_HASH;
    const valid = await bcrypt.compare(password, hash);

    if (!user || !valid) {
      recordFailedAttempt(email);
      log("login_failed", { email, ip });
      return Response.json({ error: "Invalid email or password" }, { status: 401 });
    }

    clearFailedAttempts(email);
    log("login_success", { email, ip, userId: user.id });

    // Create session...
  },
});

The key change: bcrypt.compare runs on every request. If the user exists, it compares against their real hash. If not, it compares against the dummy hash (which will never match, but takes the same time).

Generate a real dummy hash

The DUMMY_HASH above is a placeholder. Generate a real one at startup:

import bcrypt from "bcryptjs";

// Generate once at module load
const DUMMY_HASH = bcrypt.hashSync("dummy-password-never-matches", 10);

bcrypt.hashSync generates a valid bcrypt hash synchronously. We call it once when the module loads, not on every request. The password “dummy-password-never-matches” is irrelevant — no user will ever have this hash, so bcrypt.compare always returns false for the dummy path.

[!NOTE] We use hashSync (synchronous) here because it runs once at startup, not in a request handler. Using the async hash in a request handler is fine, but at module load time, synchronous is simpler.

How much does this matter?

Timing attacks against login endpoints are harder to exploit than they sound. Network latency adds noise, and the attacker needs many measurements to get a reliable signal. But they are a real attack vector, especially over fast local networks or when the attacker is on the same hosting provider.

The fix costs nothing (one dummy bcrypt call per non-existent user login attempt) and closes the leak entirely. There is no reason not to do it.

Exercises

Exercise 1: Before applying the fix, measure the response time for a login request with a registered email (wrong password) vs. an unregistered email. Use curl -w "\n%{time_total}\n" or your browser’s network tab. You should see a measurable difference (50-150ms).

Exercise 2: Apply the fix and measure again. Both should take approximately the same time.

Exercise 3: What if the user exists but has no passwordHash (they signed up with OAuth only, as in the OAuth course)? The user?.passwordHash ?? DUMMY_HASH expression handles this: if passwordHash is null, it falls back to the dummy hash. The response time is consistent regardless of whether the user has a password.

Why does the handler run bcrypt.compare even when the user does not exist?

Why is the dummy hash generated once at startup rather than on every request?

← Account Lockout What Is CSRF? →

© 2026 hectoday. All rights reserved.