Email verification exists to test one narrow claim: the person completing a time-limited challenge can receive a message at that address at that moment. In plain terms, that is what email verification actually proves. It does not prove their legal identity, continued ownership of the mailbox, or control of every session attached to an account.
That is all.
TL;DR: treat a verified email as evidence about one event, not a permanent identity badge. For an e-commerce account-erasure flow, require fresh authentication for the destructive action, perform deletion as a server-side state transition, and revoke every session independently of the email-verification flag. That gives a stolen-session attacker less room without forcing a mailbox round trip into every ordinary shopping session.
The tempting implementation is if (user.emailVerified) deleteAccount(). It is simple and wrong for the job. The chosen design below separates three questions: was a mailbox challenge completed, is the current actor freshly authenticated, and have all existing sessions become unusable?
Deletion is different.
What does the verification email actually establish?
A typical verification flow creates an unpredictable, single-use token, associates it with an account and expiry, sends it to the submitted address, then records successful redemption. The useful conclusion is limited: someone with access to that inbox received and redeemed the challenge before it expired.
That evidence still matters. It reduces accidental sign-ups with mistyped addresses, makes later security messages more likely to reach the intended mailbox, and prevents an account from casually claiming an address its user cannot access. OWASP recommends verifying an email address during sign-up when it will be used as a username, and it treats email ownership as separate from authentication.
The distinction is sharp. A completed challenge does not establish a person's civil identity. It does not show that the same person controls the inbox months later; addresses can be reassigned, shared, or compromised. It does not make a bearer session fresh, either. A valid cookie copied before verification remains a separate security fact.
So store the event precisely. A timestamp such as emailVerifiedAt is more honest and more useful than allowing a broad trustedUser boolean to spread through the codebase.
Model three states, not one trust flag
For a storefront, browsing an order history and erasing an account have different consequences. The first can use an existing valid session. The second should cross a stricter boundary because it destroys customer data and must terminate access from a forgotten laptop, a stolen phone, and any copied session token.
I would keep the decision record small:
| State | What it answers | What it must not authorize by itself |
|---|---|---|
emailVerifiedAt |
Was the mailbox challenge completed? | Account erasure |
authenticatedAt |
Did the user recently prove an authentication factor? | Continued access after deletion |
sessionEpoch |
Was this session issued under the current account epoch? | Mailbox ownership |
That split deliberately adds one integer lookup to authenticated requests in systems that use an epoch. I choose that extra account-state read because immediate invalidation is more important here than shaving one lookup from a destructive workflow. Caching the epoch can bound latency, but its expiry becomes part of the security policy: a five-minute cache can permit a stale session for five minutes unless invalidation reaches every cache entry. The alternative, trying to enumerate and delete opaque tokens scattered across devices, becomes harder to reason about once sessions are copied or cleanup partially fails. This trade-off should be written down in the design review, with the maximum permitted revocation delay and the component responsible for enforcing it, because the word "immediate" means little unless the slowest authorization path has a measurable bound. A small store can choose a direct authoritative read. A busier service may choose cache invalidation plus a short fallback lifetime. Both can preserve the invariant, but they have different failure modes and operating costs.
There are other valid storage designs, including a server-side session table with a revocation timestamp. The invariant matters more than the representation: after erasure commits, no previously issued session may authorize another request.
Implement the destructive path as one state transition
This focused TypeScript example uses generic repositories and a transaction boundary. It assumes authentication has already validated the presented session. The handler then checks recent authentication, locks the customer record, advances the session epoch, and records an erasure request in the same transaction.
type Customer = {
id: string;
status: "active" | "erasure_pending" | "erased";
emailVerifiedAt: Date | null;
sessionEpoch: number;
};
type Actor = {
customerId: string;
authenticatedAt: Date;
sessionEpoch: number;
};
interface CustomerRepository {
withTransaction<T>(work: (tx: CustomerRepository) => Promise<T>): Promise<T>;
findForUpdate(id: string): Promise<Customer | null>;
markErasurePending(id: string, nextSessionEpoch: number): Promise<void>;
}
const REAUTH_WINDOW_MS = 10 * 60 * 1000;
export async function requestAccountErasure(
actor: Actor,
customers: CustomerRepository,
now = new Date(),
): Promise<{ accepted: true }> {
const authAge = now.getTime() - actor.authenticatedAt.getTime();
if (authAge < 0 || authAge > REAUTH_WINDOW_MS) {
throw new Error("RECENT_AUTHENTICATION_REQUIRED");
}
return customers.withTransaction(async (tx) => {
const customer = await tx.findForUpdate(actor.customerId);
if (!customer || customer.status !== "active") {
throw new Error("ACCOUNT_NOT_ACTIVE");
}
if (actor.sessionEpoch !== customer.sessionEpoch) {
throw new Error("SESSION_REVOKED");
}
await tx.markErasurePending(customer.id, customer.sessionEpoch + 1);
return { accepted: true };
});
}
The ten-minute window is an example policy, not a universal security constant. Set it from the impact of the action, the strength of reauthentication available, and the friction your customers will tolerate. Record that choice so a future refactor does not silently turn ten minutes into the lifetime of a login.
Notice what the handler does not check: emailVerifiedAt. A verified mailbox may be useful for notifying the customer that erasure was requested, but sending or opening that notice must not be the mechanism that revokes sessions. Revocation happens when the authoritative account state changes.
The transaction also makes retries dull. Once the account is no longer active, a repeated request cannot increment epochs indefinitely or enqueue conflicting deletion work. In a real API, map the internal errors to stable responses without revealing whether some other customer ID exists.
Why not require another email click?
An email confirmation before deletion can add a recovery checkpoint, but it also makes mailbox availability a dependency of a privacy operation. More important, an email link is not automatically fresh authentication. If the browser session and inbox are both open on a stolen device, another click may add ceremony without adding an independent factor.
OWASP recommends reauthentication for sensitive features and after risk events, followed by session invalidation and token rotation. Account erasure is a sensitive feature. Use the account's established authentication method for that fresh proof, then notify through the verified address as a separate control.
This is the central trade-off: shoppers should not repeatedly prove mailbox access while adding items or checking delivery status, but destructive account controls deserve deliberate friction. Put the friction at the boundary with the irreversible consequence. Keep routine sessions routine.
For accounts created through a federated identity flow, the same separation applies. A claim received during sign-in and a locally completed mailbox challenge are different records unless the relying system has explicitly defined and validated their semantics. Do not collapse them into a generic verified field and hope every caller interprets it the same way.
Test the failure paths before shipping
The happy path is one test. The security argument lives in the rest.
import assert from "node:assert/strict";
import { test } from "node:test";
test("rejects an old authentication event", async () => {
const now = new Date("2026-01-01T12:20:00Z");
const actor = {
customerId: "customer_42",
authenticatedAt: new Date("2026-01-01T12:00:00Z"),
sessionEpoch: 7,
};
await assert.rejects(
requestAccountErasure(actor, fakeCustomers(), now),
/RECENT_AUTHENTICATION_REQUIRED/,
);
});
Add cases for an already erased account, a session issued under the previous epoch, two concurrent erasure requests, transaction rollback, and a worker retry. Then run an end-to-end check with two browsers: authenticate both, erase from one, and confirm the other is denied on its next protected request. No waiting.
Operationally, measure reauthentication completion and abandonment, time from the committed erasure state to denial of an old session, duplicate erasure requests, and authorization attempts using stale epochs. Log account identifiers in a pseudonymous form and avoid placing email addresses, raw tokens, or deletion payloads in telemetry.
Before copying the epoch approach, measure how quickly every authorization path observes account-state changes. A long cache lifetime can quietly become a revocation delay. Also inventory long-lived credentials outside the browser session, such as recovery codes or application tokens, and bind their validity to the same account state or revoke them explicitly.
Email delivery success is useful to observe too, but it answers a different operational question. A bounced verification message may block address confirmation. It must not leave a successfully erased account usable. Put another way, the verification concept is best explained by refusing to stretch it beyond the mailbox challenge.
Keep the claim narrow
Email verification is a mailbox-control check with a timestamp. Preserve that narrow meaning in names, schemas, authorization rules, and user-facing copy. For account erasure, rely on fresh authentication for intent and an authoritative server-side change for session revocation.
That division gives an e-commerce team a clean decision rule: add friction where the consequence is destructive, and do not let a historical email event stand in for present control. The system is easier to test because each signal has one job.
Further reading
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Top comments (0)