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

Capstone: Hardened Auth API

What changed

The auth course capstone had signup, login, logout, sessions, JWTs, and admin routes. This capstone adds every defense from this course:

DefenseFiles
Rate limitingrate-limit.ts, applied in login and reset routes
Account lockoutlockout.ts, applied in login route
Timing attack preventionDummy hash in login route
CSRF tokenscsrf.ts, applied in onResponse and state-changing routes
Refresh token rotationrefresh-tokens.ts, /token/refresh route
Token deny listtoken-deny-list.ts, checked in verifyToken
Password resetreset-tokens.ts, /forgot-password and /reset-password routes
Security headersSet in onResponse
Structured logginglogger.ts, events logged throughout

Project structure

src/
  app.ts                # setup() with routes, hooks, security headers
  server.ts             # starts the server
  db.ts                 # User type, store, seed data
  schemas.ts            # Zod schemas
  sessions.ts           # session store with expiry and deleteUserSessions
  cookies.ts            # cookie helpers
  jwt.ts                # JWT creation and verification with jti and deny list check
  auth.ts               # authenticate, requireAdmin, authenticatedAdmin
  logger.ts             # structured JSON logging
  ip.ts                 # IP extraction
  rate-limit.ts         # fixed-window rate limiter
  lockout.ts            # account lockout after failed attempts
  csrf.ts               # CSRF token generation, cookie, verification
  refresh-tokens.ts     # refresh token store with rotation and family tracking
  token-deny-list.ts    # access token deny list
  reset-tokens.ts       # password reset token store with hashing
  routes/
    auth.ts             # signup, login (hardened), logout
    users.ts            # GET /me, GET /users, DELETE /users/:id
    token-auth.ts       # token login, /token/me, /token/refresh
    reset.ts            # /forgot-password, /reset-password

The hardened login route

This is the single most changed route. Here is the complete version with every defense:

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);

    // 1. Rate limit by IP
    const ipLimit = rateLimit(`login:ip:${ip}`, 20, 60_000);
    if (!ipLimit.allowed) {
      log("rate_limited", { key: "ip", ip, email });
      return Response.json(
        { error: "Too many login attempts. Try again later." },
        {
          status: 429,
          headers: {
            "retry-after": String(Math.ceil(ipLimit.retryAfterMs / 1000)),
          },
        },
      );
    }

    // 2. Rate limit by email
    const emailLimit = rateLimit(`login:email:${email}`, 5, 60_000);
    if (!emailLimit.allowed) {
      log("rate_limited", { key: "email", ip, email });
      return Response.json(
        { error: "Too many login attempts. Try again later." },
        {
          status: 429,
          headers: {
            "retry-after": String(Math.ceil(emailLimit.retryAfterMs / 1000)),
          },
        },
      );
    }

    // 3. Check account 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)),
          },
        },
      );
    }

    // 4. Look up user (with timing-safe password check)
    const user = findByEmail(email);
    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 });
    }

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

    const sessionId = createSession(user.id);

    return Response.json(
      { user: { id: user.id, email: user.email, role: user.role } },
      {
        status: 200,
        headers: { "set-cookie": sessionCookie(sessionId) },
      },
    );
  },
});

Five defenses in one handler: IP rate limiting, email rate limiting, account lockout, timing-safe password verification, and structured logging. Each is a function call and a check. The pattern is the same throughout: call the function, check the result, early return if needed.

Test the full security stack

Brute-force protection

# Send 6 rapid login attempts with the wrong password
for i in {1..6}; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/login \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"wrong"}'
done
# First 5: 401 (wrong password)
# 6th: 429 (rate limited by email)

Account lockout

# Send 11 attempts (slowly, to avoid rate limiting)
for i in {1..11}; do
  curl -s -o /dev/null -w "Attempt $i: %{http_code}\n" -X POST http://localhost:3000/login \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"wrong"}'
  sleep 13  # wait for per-email rate limit to reset
done
# Attempts 1-10: 401
# Attempt 11: 429 (locked)

Password reset

# Request a reset
curl -X POST http://localhost:3000/forgot-password \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'

# Copy the token from the server console

# Reset the password
curl -X POST http://localhost:3000/reset-password \
  -H "Content-Type: application/json" \
  -d '{"token":"TOKEN_HERE","newPassword":"newadmin123"}'

# Old sessions are invalidated. Log in with the new password.

Security headers

curl -v http://localhost:3000/health 2>&1 | grep -E 'x-content-type|x-frame|referrer-policy'
# x-content-type-options: nosniff
# x-frame-options: DENY
# referrer-policy: strict-origin-when-cross-origin

What you built

From the auth course baseline to this hardened version:

Before (auth course)After (this course)
No rate limitingPer-IP and per-email rate limiting
No lockout10-attempt lockout with auto-recovery
Timing leak on loginDummy hash for non-existent users
No CSRF protection beyond SameSiteCSRF tokens for cookie-based routes
24-hour access tokens15-minute access tokens + refresh rotation
No token revocationDeny list for access tokens
No password resetFull reset flow with hashed, expiring, single-use tokens
No security headersHSTS, nosniff, DENY, CSP, referrer-policy
Plain text logsStructured JSON logging of all security events

Every defense is a plain function. No security framework, no middleware library. You understand what each one does because you wrote it.

Challenges

Challenge 1: Add 2FA support. After a successful password check, require a TOTP code. Use the otpauth npm package to generate and verify TOTP codes. Store the TOTP secret on the user record. Add POST /me/2fa/enable and POST /me/2fa/verify routes.

Challenge 2: Add email verification on signup. When a user signs up, mark their account as emailVerified: false. Send a verification token via email (same pattern as password reset). Add a GET /verify-email?token=... route that verifies the token and sets emailVerified: true. Prevent unverified users from accessing protected routes.

Challenge 3: Add a suspicious activity alert. When a login_success event comes from an IP that has never been associated with that user before, log a suspicious_login event. In production, you would send the user an email: “A new login was detected from IP x.x.x.x.”

How many separate security checks does the hardened login route perform before reaching the password verification step?

What is the most important thing to remember when adding security defenses?

← Security Checklist Back to course →

© 2026 hectoday. All rights reserved.