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 Account Deletion

Requesting deletion

The user requests deletion. We record the request and set a date for hard delete (30 days later).

// src/routes/deletion.ts
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { authenticate } from "../auth.js";
import { requireRecentAuth } from "../step-up.js";

const GRACE_PERIOD = 30 * 24 * 60 * 60 * 1000; // 30 days

export const deletionRoutes = group([
  // Request account deletion
  route.post("/me/delete", {
    resolve: (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const reauth = requireRecentAuth(c.request);
      if (reauth instanceof Response) return reauth;

      // Check for existing request
      const existing = db
        .prepare("SELECT id, cancelled FROM deletion_requests WHERE user_id = ?")
        .get(user.id) as any;

      if (existing && !existing.cancelled) {
        return Response.json({ error: "Deletion already requested" }, { status: 409 });
      }

      const executeAt = new Date(Date.now() + GRACE_PERIOD).toISOString();
      const id = crypto.randomUUID();

      if (existing) {
        // Reuse the row (was previously cancelled)
        db.prepare(
          "UPDATE deletion_requests SET requested_at = datetime('now'), execute_at = ?, cancelled = 0 WHERE user_id = ?",
        ).run(executeAt, user.id);
      } else {
        db.prepare("INSERT INTO deletion_requests (id, user_id, execute_at) VALUES (?, ?, ?)").run(
          id,
          user.id,
          executeAt,
        );
      }

      console.log(`\nπŸ“§ Account deletion requested for ${user.email}. Executes at ${executeAt}\n`);

      return Response.json({
        message: "Account scheduled for deletion.",
        executeAt,
        cancelUrl: "POST /me/delete/cancel",
      });
    },
  }),

  // Cancel deletion
  route.post("/me/delete/cancel", {
    resolve: (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const result = db
        .prepare("UPDATE deletion_requests SET cancelled = 1 WHERE user_id = ? AND cancelled = 0")
        .run(user.id);

      if (result.changes === 0) {
        return Response.json({ error: "No active deletion request" }, { status: 404 });
      }

      return Response.json({ message: "Deletion cancelled. Your account will not be deleted." });
    },
  }),

  // Check deletion status
  route.get("/me/delete/status", {
    resolve: (c) => {
      const user = authenticate(c.request);
      if (user instanceof Response) return user;

      const request = db
        .prepare("SELECT execute_at, cancelled FROM deletion_requests WHERE user_id = ?")
        .get(user.id) as { execute_at: string; cancelled: number } | undefined;

      if (!request || request.cancelled) {
        return Response.json({ scheduled: false });
      }

      return Response.json({
        scheduled: true,
        executeAt: request.execute_at,
        daysRemaining: Math.ceil(
          (new Date(request.execute_at).getTime() - Date.now()) / (24 * 60 * 60 * 1000),
        ),
      });
    },
  }),
]);

Notice: deletion requires step-up auth (requireRecentAuth). An attacker with a stale session cannot delete the account. Cancellation does not require step-up β€” the user should be able to cancel easily.

The hard delete job

After the grace period, a scheduled job runs the hard delete:

// src/deletion-job.ts
import db from "./db.js";

export function processExpiredDeletions(): void {
  const now = new Date().toISOString();
  const expired = db
    .prepare(
      "SELECT dr.user_id, u.email FROM deletion_requests dr JOIN users u ON dr.user_id = u.id WHERE dr.execute_at <= ? AND dr.cancelled = 0",
    )
    .all(now) as { user_id: string; email: string }[];

  for (const { user_id, email } of expired) {
    hardDeleteUser(user_id);
    console.log(`\nπŸ—‘οΈ  Account permanently deleted: ${email}\n`);
  }
}

function hardDeleteUser(userId: string): void {
  // Delete in dependency order
  db.prepare("DELETE FROM email_verifications WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM totp_secrets WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM passkeys WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM device_sessions WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM magic_links WHERE email = (SELECT email FROM users WHERE id = ?)").run(
    userId,
  );
  db.prepare("DELETE FROM deletion_requests WHERE user_id = ?").run(userId);

  // Handle shared data (next lesson covers this in detail)
  db.prepare("UPDATE notes SET created_by = 'deleted-user' WHERE created_by = ?").run(userId);

  // Delete memberships and invites
  db.prepare("DELETE FROM memberships WHERE user_id = ?").run(userId);
  db.prepare("DELETE FROM invites WHERE invited_by = ?").run(userId);

  // Finally, delete the user
  db.prepare("DELETE FROM users WHERE id = ?").run(userId);
}

// Run every hour
setInterval(processExpiredDeletions, 60 * 60 * 1000);

The hard delete cascades through every table. Shared data (notes created by the user in an organization) is anonymized rather than deleted β€” the created_by field is set to 'deleted-user'.

The flow

# Request deletion (requires step-up)
curl -b cookies.txt -X POST http://localhost:3000/auth/confirm \
  -H "Content-Type: application/json" \
  -d '{"password":"password123"}'

curl -b cookies.txt -X POST http://localhost:3000/me/delete
# { "message": "Account scheduled for deletion.", "executeAt": "2025-02-15T..." }

# Check status
curl -b cookies.txt http://localhost:3000/me/delete/status
# { "scheduled": true, "executeAt": "...", "daysRemaining": 30 }

# Change your mind
curl -b cookies.txt -X POST http://localhost:3000/me/delete/cancel
# { "message": "Deletion cancelled." }

Exercises

Exercise 1: Request deletion. Check the deletion_requests table. Cancel it. Check again.

Exercise 2: Request deletion and let the grace period expire (set GRACE_PERIOD to 5 seconds for testing). Run processExpiredDeletions(). Verify the user and all related data are deleted.

Exercise 3: After deletion, try to log in with the deleted user’s credentials. It should fail (user does not exist).

Why does cancellation not require step-up authentication?

← The Right to Be Forgotten Data Cleanup →

© 2026 hectoday. All rights reserved.