Rebounder Tech Blog

Written by the people who actually run these systems in production.

email_exists on createUser Hides a Silent Signup Failure

Published About 6 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

When Supabase Auth's createUser fails on a duplicate email, the code never tells someone else's account apart from the caller's own prior attempt — both hit the same auth_exists branch.

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, createUser fails with email_exists, and the function returns reason: "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.

Frequently asked questions

Q1What does the customer actually see when email_exists blocks account creation?

Right after a contract is signed, a process auto-provisions a submission account. If an Auth user with that same email already exists, the call fails. Previously this ended at an internal Slack warning only — nothing changed on the customer's screen or inbox.

Q2Did the fix resolve the duplicate-email condition itself?

No. Detecting email_exists, and any logic to look up and reuse an existing Auth user, were left unchanged. What changed is only whether the caller opts in to notifying the customer. Only the automatic rail from the contract-signing webhook turns that on.

Q3Why doesn't the manual provisioning path send the same customer email?

Manual provisioning (/admin/users or allocating to an organization) is done by staff watching the screen, so they react immediately. If that path also emailed the customer, every retry would send a duplicate. The automated rail has no one watching, so the email is the only signal there.

Environment verified

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2 / React 19.2.4
  • Silent Auth-provisioning gap found via E2E on 2026-07-22, fixed for one path on 2026-07-23

What this article is based on

  • TypeScript file lines 38-49commit e5fe39e
  • TypeScript file lines 118-182commit e5fe39e
  • TypeScript file lines 184-196commit e5fe39e
  • TypeScript file lines 90-120commit f170523

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.