Use an immutable internal user ID for identity decisions, and treat email as a mutable operational lookup key. That split matters most during a healthtech account deletion: every session must be revoked for the same subject even if the person changed email, reused an address, or has several normalized email forms in surrounding systems.
The decision rule is crisp: authorization, session ownership, audit correlation, and deletion orchestration join on userId; support search and delivery workflows may start with email, but they must resolve it to exactly one current userId before changing state.
Email addresses change. Identity must not.
How should stable account lookup use user IDs and email for operations?
Think of the account model as two lanes. The identity lane answers, "Which security subject is this?" Its key is an opaque, immutable userId. The operations lane answers, "How can staff or a workflow find and contact this person right now?" Email belongs there, along with verification state and the time at which the address became current. The lanes meet through an explicit resolution step; they aren't interchangeable.
That's the boundary.
| Decision | Stable userId
|
|
|---|---|---|
| Authorize an account mutation | Use it | Never use it directly |
| Revoke every session | Use it | Resolve first |
| Correlate deletion events | Use it | Avoid copying it |
| Help an operator find an account | Display after resolution | Accept as search input |
Before this split, a deletion handler often receives an email and fans it out to the session store, profile store, audit index, and notification system. Each dependency quietly gets to decide what equality means. One lowercases the value. Another preserves it. A third contains an older address. The result is a dangerous shape: the command appears account-wide, but its reach depends on mutable text copied at different times.
After the split, the flow is a short diagram in words: authenticated deletion request -> trusted subject ID -> account deletion coordinator -> session revocation and owned-data deletion by subject ID. A support-assisted request adds one guarded step at the front: verified email -> unique active account -> subject ID. From then on, the exact same coordinator runs.
That boundary also follows OWASP's broader authentication guidance: user identifiers should be handled as identifiers, authentication responses should avoid leaking whether an account exists, and sensitive account changes should require appropriate reauthentication. A public lookup endpoint that returns "email found" or "email missing" turns an operational convenience into an enumeration surface. Keep lookup behind an authorized workflow and return a uniform response at the public edge.
One copyable deletion flow
The useful abstraction is small. Repositories expose subject-based mutations; only the account directory knows how to resolve a normalized email. This example deliberately keeps email out of session records and deletion commands.
type UserId = string & { readonly __brand: "UserId" };
type Account = {
userId: UserId;
email: string;
emailVerified: boolean;
status: "active" | "deleting" | "deleted";
sessionEpoch: number;
};
interface AccountDirectory {
findActiveByNormalizedEmail(email: string): Promise<Account | null>;
markDeleting(userId: UserId): Promise<void>;
markDeleted(userId: UserId): Promise<void>;
}
interface SessionStore {
revokeAllForUser(userId: UserId): Promise<void>;
}
interface OwnedDataStore {
deleteForUser(userId: UserId): Promise<void>;
}
interface SecurityEvents {
record(event: {
action: "account_deletion_started" | "account_deletion_completed";
subjectId: UserId;
}): Promise<void>;
}
function normalizeEmailForLookup(email: string): string {
return email.trim().toLowerCase();
}
async function resolveOperationalLookup(
rawEmail: string,
accounts: AccountDirectory,
): Promise<UserId | null> {
const account = await accounts.findActiveByNormalizedEmail(
normalizeEmailForLookup(rawEmail),
);
return account?.emailVerified ? account.userId : null;
}
async function deleteAccountAndRevokeSessions(
userId: UserId,
accounts: AccountDirectory,
sessions: SessionStore,
data: OwnedDataStore,
events: SecurityEvents,
): Promise<void> {
await accounts.markDeleting(userId);
await events.record({ action: "account_deletion_started", subjectId: userId });
await sessions.revokeAllForUser(userId);
await data.deleteForUser(userId);
await accounts.markDeleted(userId);
await events.record({ action: "account_deletion_completed", subjectId: userId });
}
The coordinator accepts no email. Good. An authenticated self-service flow gets userId from the trusted authentication context rather than from request body data. A support tool can call resolveOperationalLookup, but a null result should stay inside that authorized tool; the customer-facing response must not reveal whether the address maps to an account. The handler should also be idempotent at the workflow boundary so a retry continues the same deletion rather than creating a second identity operation.
The order is intentional. Marking the account as deleting blocks new sessions before existing sessions are revoked. The event stream carries the pseudonymous subject ID, which makes start and completion observable without turning email into the correlation key. Monitor the gap between those two events, count deletion attempts that don't complete, and alert on a session accepted after deletion begins. Logs should preserve the stable correlation key while avoiding unnecessary copies of the address.
Now test it.
There is still a hard systems question: what counts as "every session"? The answer is the complete set of session issuers and validators owned by the account domain. If that inventory is incomplete, no choice of lookup key can prove revocation coverage. Write the inventory down, test each issuer, and make acceptance depend on account status or a revocation marker keyed by userId. Don't let a successful database update stand in for an end-to-end security result.
Failure modes and deployment checks
Start with the failure that looks harmless: using email as the session subject. A later email change leaves old sessions attached to the old string, so a deletion keyed by the new address can miss them. Reassignment is worse. If an address becomes associated with a different account, historical records keyed only by that address can become ambiguous. An immutable userId prevents the join from changing meaning over time; email history, where the application truly needs it, remains a separately governed operational record.
Normalization needs a narrow contract too. Trimming and case normalization can make operator search predictable, but an application shouldn't invent provider-specific equivalence rules and then treat the result as proof of identity. Uniqueness rules belong in the account directory, under one policy. Verification proves control of an address for a workflow; it does not turn that address into an eternal subject identifier.
Deploy the split in observable stages. First, add userId to session and audit records while retaining the existing lookup for reads. Backfill with a validated account mapping, reject ambiguous rows, and compare counts by issuer. Next, move revocation and deletion writes to userId. Finally, stop using email for security joins. During the transition, a useful test matrix covers an email change, concurrent session creation, repeated deletion requests, a stale support search, and a session presented after deletion begins. The expected result is always stated in subject terms: one account changes state, all sessions for its ID become invalid, and an unrelated account remains untouched.
This design does carry costs. Operators can no longer paste an address into every datastore, and incident tooling needs a controlled resolution service. The extra indirection also means the account directory is part of the operational lookup path. For a small internal system with no authentication, no mutable addresses, and no cross-store account lifecycle, a single natural key may be acceptable. It is not suitable for a healthtech account flow where deletion and session revocation are security actions. There, reduced friction for staff does not justify ambiguous identity joins.
What about support search and immediate logout objections?
The first objection is practical: support teams know email, not opaque IDs. They don't need to memorize IDs. Give the authorized support interface an exact email search that displays the current account and then carries the resolved userId invisibly through every mutation. Require an explicit selection when policy permits multiple candidates, record the acting operator separately from the subject, and avoid exposing account existence through unauthenticated responses. Email remains excellent input for search; it is poor input for authorization.
The second objection is that revoking a server-side session list may not instantly invalidate every already-issued credential. The architecture has to make revocation part of validation, not merely cleanup: validators can check account status, a per-user session epoch, or another subject-keyed revocation state according to the system's credential design. The trade-off is more validation work versus a longer window in which a credential can remain usable. For an account deletion that promises every session is revoked, choose the mechanism whose enforcement point actually sees the revocation signal; don't claim immediate logout from deletion of records that validators never consult.
There is no universal retention or retry policy hidden in the lookup model. Those choices depend on the system's legal requirements, data inventory, credential format, and failure model. The invariant is narrower and testable: mutable contact data may locate a subject, but only the stable subject ID crosses the boundary into identity-changing work.
Top comments (0)