hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Production Auth Patterns with @hectoday/http

Before They Start

  • Why Production Auth Is Different
  • Project Setup

Email Verification

  • Why Verify Emails
  • Building Email Verification
  • Restricting Unverified Accounts

Session Management

  • Tracking Sessions Across Devices
  • Listing and Revoking Sessions
  • Session Security

Step-Up Authentication

  • What Is Step-Up Auth
  • Building Step-Up Auth
  • Applying Step-Up to Sensitive Routes

Account Deletion

  • The Right to Be Forgotten
  • Building Account Deletion
  • Data Cleanup

SAML and Enterprise SSO

  • What Is SAML
  • Building a SAML Service Provider
  • Just-in-Time Provisioning

Putting It All Together

  • Production Auth Checklist
  • Capstone: Production-Ready Auth

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?

← What Is Step-Up Auth Applying Step-Up to Sensitive Routes →

© 2026 hectoday. All rights reserved.