email_exists on createUser Hides a Silent Signup Failure
Conclusion When Supabase Auth's auth.admin.createUser fails with a duplicate email (email_exists), the code never distinguishes whether the cause is "someone else's existing account" or "a collision with the caller's o
Conclusion
When Supabase Auth's auth.admin.createUser fails with a duplicate email (email_exists), the code never distinguishes whether the cause is "someone else's existing account" or "a collision with the caller's own prior attempt." In the process that auto-provisions a customer's submission account on every contract signature (provisionCustomerAccount), this failure β until an E2E test caught it on 2026-07-22 β ended at an internal Slack warning only, with nothing changing on the customer's screen or in their inbox. The scope of the 2026-07-23 fix is narrow: it does not change the email-duplicate detection itself, or the logic for looking up and reusing an existing Auth user. All it changes is letting the caller opt in to notifying the customer, and that option is only turned on for the automatic rail triggered by the contract-signing webhook.
Symptom
- Once a contract is signed, a customer submission account is auto-provisioned in Supabase Auth (
provisionCustomerAccount) - If a Supabase Auth user already exists for the email being used,
createUserfails withemail_exists, and the function returnsreason: "auth_exists" - Previously, this branch only sent an internal warning via
notifySlackβ no email ever reached the customer, and nothing changed on screen - From the customer's side, they just waited for the submission-account instructions that should follow signing a contract, while in reality nothing was progressing
Cause
provisioning.ts's idempotency check only looks at the profiles table β it never checks whether the Auth-side user is still alive. The code's own comment states this design decision outright.
// provisioning.ts (e5fe39eaf5, lines 118-123)
// 1. Idempotency check: one account per company (Β§2.4 = company_id Γ role Γ is_active).
// Counted per role (an advertiser's "customer" role and an organization's
// "group_manager" role are separate accounts). A stale, disabled (is_active=false)
// account will trigger a "try to re-provision" path, but if an Auth user with the
// same email is still alive, it later fails with email_exists β auth_exists,
// falling through to a Slack warning + manual handling (never issue duplicates / Β§2.6).
In other words, this function was deliberately built to attempt re-provisioning whenever profiles has no active row. But if an Auth user with the same email is still present β whether that's a leftover from the caller's own earlier attempt, or an unrelated person's existing account β createUser fails with the same email_exists, and the function returns the same auth_exists either way, without distinguishing the two.
// provisioning.ts (e5fe39eaf5, lines 148-178)
if (createErr || !created?.user) {
if (isEmailExistsError(createErr)) {
// B-1 (actually observed via E2E on 2026-07-22): if an Auth user with the same
// email already exists, provisioning fails, and previously this ended at a
// Slack warning only β a silent hole where the customer was told nothing.
if (!input.notifyCustomerOnConflict)
return { ok: false, reason: "auth_exists", email };
const customerNotified = await sendEmail({ /* ... */ });
return { ok: false, reason: "auth_exists", email, customerNotified };
}
return { ok: false, reason: "error", detail: createErr?.message };
}
As the code comment states, at the point the E2E test found this, the notifyCustomerOnConflict branch did not exist at all β auth_exists always ended at a Slack warning and nothing else. There is also a rollback path that deletes the Auth user if the subsequent insert into profiles fails,
// provisioning.ts (e5fe39eaf5, lines 184-196)
if (profErr) {
await admin.auth.admin.deleteUser(created.user.id);
return { ok: false, reason: "error", detail: profErr.message };
}
but the return value of that deleteUser call is never checked. If the deletion itself fails, an "orphaned" Auth user with no corresponding profiles row is left behind, and every subsequent provisioning attempt will from then on hit the same auth_exists branch.
The fix
On 2026-07-23, a notifyCustomerOnConflict argument was added to ProvisionInput, letting the caller choose whether the customer gets emailed when auth_exists occurs.
// provisioning.ts (e5fe39eaf5, lines 38-49)
/**
* Whether to send the customer a notice email when auth_exists occurs (an Auth user
* with the same email already exists) (B-1). Only true on the automated rail
* (the contract-signing webhook). Manual provisioning (/admin/users, or allocating
* to an organization) does not send it, since staff see the result on screen and
* can act on it directly (this also avoids duplicate sends on every retry).
*/
notifyCustomerOnConflict?: boolean;
The contract-signing webhook (contract-signed.ts) calls this with true, so the customer now also gets an email in addition to the Slack warning.
// contract-signed.ts (f1705230, lines 99-114)
const r = await provisionCustomerAccount({
companyId,
// On the automated rail, don't leave the customer silent even on auth_exists
// (B-1 β send the notice email).
notifyCustomerOnConflict: true,
});
// ...
: r.reason === "auth_exists"
? `β οΈ Submission account could not be provisioned: email ${escapeSlack(r.email ?? "?")} conflicts with an existing user.` +
`Customer notice email was ${r.customerNotified ? "sent" : "sentβ (failed)"}. ` +
`Please provision manually with a different email address from ${usersLink} ${link}`
The manual provisioning path (/admin/users, or allocating to an organization) does not pass this flag, so it stays at its default of false. Since staff on the manual path see the result on screen in real time and can act on it, sending a customer email here too would mean every retry by staff sends the customer a duplicate notice. That's the reason the scope of the fix is described narrowly as "limited to one path."
What actually happens when auth_exists occurs hasn't changed either. The Slack message still only suggests "please provision manually with a different email address" β there is still no path that looks up and reuses the conflicting existing Auth user.
Generalizing further
The cost of "figuring out whose existing record this actually is, when an existence check fails" gets deferred in most implementations. As this function's own comment shows, the design here deliberately decided: "retry provisioning if profiles has no active row, but fall through to a Slack warning plus manual handling if the Auth side is still alive." That's the right call to avoid issuing duplicates or wrongly linking an account to the wrong company or staff member β but the cost of that call is that the code never investigates whether a conflict traces back to the same person's past failure or to an unrelated third party.
The reason the current fix stops at "tell the customer something" without going as far as "identify the cause of the conflict" is that the underlying design decision itself was never revisited. For a customer who hits auth_exists on the same email, the system can only suggest "try your existing login, or get a new account issued under a different email" β either way, the system has no way to answer what that person actually did here in the past. Before deciding how far up the chain to notify about an existence-check failure, it's worth first checking whether the system is even built to tell what that failure actually means.
Originally published at tech.rebounder.jp.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.