Building Step-Up Auth
The re-authentication endpoint
The user submits their password, TOTP code, or passkey. On success, the session is marked with a lastAuthAt timestamp.
First, update the session to track re-authentication:
// src/sessions.ts — add to session type
// { userId, createdAt, lastAuthAt?: number }
export function recordReAuth(sessionId: string): void {
const session = store.get(sessionId);
if (session) {
session.lastAuthAt = Date.now();
}
}
export function getLastAuthAt(sessionId: string): number | undefined {
return store.get(sessionId)?.lastAuthAt;
} The re-authentication route:
// src/routes/step-up.ts
import { route, group } from "@hectoday/http";
import bcrypt from "bcryptjs";
import db from "../db.js";
import { authenticate } from "../auth.js";
import { getSessionId } from "../cookies.js";
import { recordReAuth } from "../sessions.js";
import { verifyTOTPCode } from "../totp.js";
export const stepUpRoutes = group([
route.post("/auth/confirm", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
const { password, code } = body;
let verified = false;
// Option 1: Password
if (password) {
const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(user.id) as {
password_hash: string;
};
verified = await bcrypt.compare(password, row.password_hash);
}
// Option 2: TOTP code
if (!verified && code) {
const totpRow = db
.prepare("SELECT secret FROM totp_secrets WHERE user_id = ? AND enabled = 1")
.get(user.id) as { secret: string } | undefined;
if (totpRow) {
verified = verifyTOTPCode(totpRow.secret, code);
}
}
if (!verified) {
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
// Record the re-authentication
const sessionId = getSessionId(c.request)!;
recordReAuth(sessionId);
return Response.json({ message: "Identity confirmed.", expiresIn: "5 minutes" });
},
}),
]); The user can confirm with their password or a TOTP code. On success, recordReAuth stores the current timestamp on the session.
The requireRecentAuth check
// src/step-up.ts
import { getSessionId } from "./cookies.js";
import { getLastAuthAt } from "./sessions.js";
const STEP_UP_WINDOW = 5 * 60 * 1000; // 5 minutes
export function requireRecentAuth(request: Request): true | Response {
const sessionId = getSessionId(request);
if (!sessionId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const lastAuthAt = getLastAuthAt(sessionId);
if (!lastAuthAt || Date.now() - lastAuthAt > STEP_UP_WINDOW) {
return Response.json(
{
error: "Re-authentication required",
action: "POST /auth/confirm with your password or TOTP code",
},
{ status: 403 },
);
}
return true;
} The function checks whether the session has a recent re-authentication (within 5 minutes). If not, it returns 403 with instructions to re-authenticate.
Using it in route handlers
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;
// ... change email (only runs if re-authenticated within 5 minutes)
},
}); Same true | Response pattern. The checks stack:
const user = authenticate(c.request); // Are you logged in?
if (user instanceof Response) return user;
const reauth = requireRecentAuth(c.request); // Did you prove your identity recently?
if (reauth instanceof Response) return reauth; The flow
# Try to change email without re-authentication
curl -b cookies.txt -X PUT http://localhost:3000/me/email \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
# 403: { "error": "Re-authentication required", "action": "POST /auth/confirm ..." }
# Re-authenticate
curl -b cookies.txt -X POST http://localhost:3000/auth/confirm \
-H "Content-Type: application/json" \
-d '{"password":"password123"}'
# { "message": "Identity confirmed.", "expiresIn": "5 minutes" }
# Now change email (within 5-minute window)
curl -b cookies.txt -X PUT http://localhost:3000/me/email \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
# { "message": "Email updated." } Why 5 minutes?
The window should be short enough that a session hijacker cannot use a stale re-authentication, but long enough that the user can complete multi-step operations (like changing email, then updating 2FA settings) without re-authenticating for each one.
5 minutes is the standard (used by GitHub, Google, and most security-focused apps). Adjust based on your threat model.
Exercises
Exercise 1: Add requireRecentAuth to a route. Try accessing it without re-authenticating. You should get 403.
Exercise 2: Re-authenticate with your password. Access the route within 5 minutes. It should work.
Exercise 3: Wait 5 minutes (or set STEP_UP_WINDOW to 10 seconds). Try again. It should require re-authentication again.
Why does step-up auth use a time window instead of a single-use flag?