Applying Step-Up to Sensitive Routes
Which routes get step-up
Apply requireRecentAuth to every route where a compromised or stale session could cause serious damage:
// Email change
route.put("/me/email", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const reauth = requireRecentAuth(c.request);
if (reauth instanceof Response) return reauth;
const body = await c.request.json();
const newEmail = (body as any).email;
if (!newEmail) return Response.json({ error: "Email required" }, { status: 400 });
// Check for duplicate
const existing = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?")
.get(newEmail, user.id);
if (existing) return Response.json({ error: "Email already in use" }, { status: 409 });
// Update email and reset verification
db.prepare("UPDATE users SET email = ?, email_verified = 0 WHERE id = ?")
.run(newEmail, user.id);
// Send verification to new email
const token = await createVerificationToken(user.id);
console.log(`\n📧 Verify new email ${newEmail}:\nhttp://localhost:3000/verify-email?token=${token}\n`);
return Response.json({ message: "Email updated. Verify your new email address." });
},
}),
// Password change
route.put("/me/password", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const reauth = requireRecentAuth(c.request);
if (reauth instanceof Response) return reauth;
const body = await c.request.json();
const newPassword = (body as any).newPassword;
if (!newPassword || newPassword.length < 8) {
return Response.json({ error: "Password must be at least 8 characters" }, { status: 400 });
}
const hash = await bcrypt.hash(newPassword, 10);
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, user.id);
// Rotate the session (invalidate old sessions)
const newSessionId = rotateSession(user.sessionId, user.id, c.request);
return Response.json(
{ message: "Password changed. All other sessions have been revoked." },
{ headers: { "set-cookie": sessionCookie(newSessionId) } },
);
},
}), Notice: email change resets email_verified to 0 and sends a new verification email. Password change rotates the session (from the session security lesson).
Routes that already have step-up from other courses
The 2FA course’s disable endpoint already requires a TOTP or recovery code. That is a form of step-up auth — the user must prove they have the second factor. You can add requireRecentAuth on top for an additional layer, or keep the existing TOTP requirement as sufficient.
The authorization course’s API key creation requires org:settings permission. Adding requireRecentAuth here means an attacker with a hijacked session cannot create long-lived API keys without re-authenticating.
The pattern
Every sensitive route follows the same structure:
const user = authenticate(c.request); // Step 1: Who are you?
if (user instanceof Response) return user;
const reauth = requireRecentAuth(c.request); // Step 2: Prove it recently
if (reauth instanceof Response) return reauth;
// Step 3: Perform the action What the frontend does
When the frontend receives 403 with "Re-authentication required", it shows a modal:
┌──────────────────────────────────────┐
│ Confirm your identity │
│ │
│ [Password] │
│ — or — │
│ [Enter TOTP code] │
│ — or — │
│ [Use passkey 🔑] │
│ │
│ [Confirm] │
└──────────────────────────────────────┘ After confirmation, the frontend retries the original request. The re-authentication is valid for 5 minutes, so subsequent sensitive actions do not trigger the modal again.
Exercises
Exercise 1: Add step-up to the email change route. Try changing email without re-authenticating (403). Re-authenticate, then change email (200).
Exercise 2: Add step-up to API key creation. Try creating a key without re-authenticating. It should require confirmation.
Exercise 3: Re-authenticate, then perform three sensitive actions within 5 minutes. Only the first should trigger the confirmation modal.
Why does changing the email reset email_verified to 0?