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:
| Defense | Files |
|---|---|
| Rate limiting | rate-limit.ts, applied in login and reset routes |
| Account lockout | lockout.ts, applied in login route |
| Timing attack prevention | Dummy hash in login route |
| CSRF tokens | csrf.ts, applied in onResponse and state-changing routes |
| Refresh token rotation | refresh-tokens.ts, /token/refresh route |
| Token deny list | token-deny-list.ts, checked in verifyToken |
| Password reset | reset-tokens.ts, /forgot-password and /reset-password routes |
| Security headers | Set in onResponse |
| Structured logging | logger.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 limiting | Per-IP and per-email rate limiting |
| No lockout | 10-attempt lockout with auto-recovery |
| Timing leak on login | Dummy hash for non-existent users |
| No CSRF protection beyond SameSite | CSRF tokens for cookie-based routes |
| 24-hour access tokens | 15-minute access tokens + refresh rotation |
| No token revocation | Deny list for access tokens |
| No password reset | Full reset flow with hashed, expiring, single-use tokens |
| No security headers | HSTS, nosniff, DENY, CSP, referrer-policy |
| Plain text logs | Structured 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?