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?