Short answer: put exact lookup, profile updates, and deletion behind separate, auditable admin commands, then migrate them in three phases with the old provider still available for read verification. The constraint is auditability: a fast endpoint that cannot prove who changed which field is not an admin system.
This is the shape a media back office needs. Editors need to find one account quickly, support needs to correct a display name, and privacy requests need a controlled delete. Those are different risk levels, even when they sit on one screen. Treating them as ordinary CRUD creates the predictable audit question: why could a broad update overwrite a verified email? That is a design problem, not a missing log line.
What should an admin operation prove before it changes a user?
Start with an immutable user identifier. Email is a search key, not an identity key: it can be normalized, changed, or shared by an organization. The lookup response should return a narrow projection (id, status, email hash, roles, and timestamps), with an explicit reason for the access. Never return password hashes, recovery tokens, or every profile field just because the caller is an administrator.
Authorization belongs at the operation boundary. A role that can look up a record may not edit roles, and an operator who can edit a profile may not erase it. Record the actor, target id, request id, reason, before-and-after field names, and result. Store the event in append-only storage; an application log that an operator can edit is not an audit trail.
How do exact lookup and profile updates stay predictable during migration?
Use a strict command contract instead of accepting an arbitrary JSON patch. Exact lookup can accept a UUID or a separately indexed, normalized email. Updates should enumerate allowed fields and reject an empty or unknown set. Add an optimistic version so two support tabs cannot silently overwrite one another.
type ProfilePatch = {
displayName?: string;
locale?: string;
marketingOptIn?: boolean;
};
function validatePatch(patch: ProfilePatch): string[] {
const errors: string[] = [];
if (patch.displayName !== undefined && (patch.displayName.length < 1 || patch.displayName.length > 120)) {
errors.push("displayName length");
}
if (patch.locale !== undefined && !/^[a-z]{2}(?:-[A-Z]{2})?$/.test(patch.locale)) {
errors.push("locale format");
}
if (Object.keys(patch).length === 0) errors.push("empty patch");
return errors;
}
async function updateProfile(userId: string, patch: ProfilePatch, expectedVersion: number) {
const errors = validatePatch(patch);
if (errors.length) throw new Error(`invalid profile update: ${errors.join(", ")}`);
return db.users.update({ userId, patch, expectedVersion });
}
During migration, dual-read a small, privacy-safe sample: fetch by immutable id from both systems, compare normalized fields, and emit a mismatch metric without exposing values. In one useful test, the comparison record contains only a keyed digest of the email, the role set, the version, and the source timestamps, so an engineer can investigate a mismatch without exporting a mailbox. I also keep the old response beside the new response for a short replay window; that catches normalization differences such as en-US versus en-us before they reach an editor. Do not dual-write deletion. A delete that succeeds in one system and queues in another needs a durable outbox and a visible state such as deletion_pending; pretending it is finished creates a compliance gap. The outbox event should carry an idempotency key, a schema version, and a deadline, then be retried with backoff while a dashboard shows the oldest pending request. That extra bookkeeping feels slow during a migration, but it is what lets an auditor follow one request from approval to every downstream confirmation.
Keep it boring.
A controlled deletion is a workflow, not a DELETE statement
Deletion needs a policy decision before it needs SQL. Check legal holds, active sessions, linked media ownership, invoices, and retention obligations. Then create a deletion request with an approval record and an expiry. Re-authentication or step-up authentication should protect the approval, and the request should be idempotent so a retried job cannot remove unrelated data.
The worker should revoke sessions first, block new login, remove or anonymize personal fields according to policy, and record each subsystem's result. Keep a tombstone containing only the request id, user id, policy version, and timestamps when audit rules require proof that the action happened. OWASP's Authentication Cheat Sheet also recommends treating account recovery and authentication events as security-sensitive, which is why deletion and password-reset administration belong in the same monitored control plane.
One short warning.
Do not put a raw provider token in an admin browser. Keep provider credentials server-side, scope them to the operation, and make the admin API the only place that can issue a reset or deletion command. The old managed provider is not suitable when you cannot export a complete audit history or enforce field-level authorization; keep it for a read-only verification window, or choose a system that exposes those controls.
The three-phase migration I would measure
Phase one is inventory: list every user field, session, recovery artifact, and downstream owner. Measure lookup latency, update rejection rate, and the percentage of records with a stable id. Phase two is shadow verification: compare reads, sample audit events, and rehearse a deletion in a non-production tenant. Phase three is cutover: route writes to the new command service, retain read fallback briefly, and set a dated removal plan for the provider integration.
The numbers matter. Track p95 lookup latency, mismatch rate, failed step-up attempts, deletion completion time, and the count of audit events missing actor or reason. Your mileage may vary on the retention window; legal counsel and regional policy decide that, not a default in code. I would not copy this design until those metrics have owners and alert thresholds.
The trade-off is deliberate: a command service and outbox add moving parts and a little latency. They buy replayable evidence and safer retries. For a small internal tool with no regulated data, a simpler database transaction may be enough. For a media platform migrating off a managed provider, controlled complexity is the cheaper risk.









