Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Supabase Authのemail_existsはメール重複でユーザー作成に失敗、救済できず無音の穴に

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Supabase Authのauth.admin.createUserがメール重複で失敗したとき、原因が別人の既存アカウントか自分自身の発行の衝突かをコードは区別せず、どちらも同じauth_existsとしてSlack警告だけで止まっていた。

結論

Supabase Authのauth.admin.createUserがメール重複(email_exists)で失敗したとき、原因が「別人の既存アカウント」なのか「自分自身の以前の発行で生まれた衝突」なのかを、コードは区別しない。 顧客の入稿用アカウントを契約署名のたびに自動発行する処理(provisionCustomerAccount)で、この失敗は2026-07-22のE2Eテストまで、Slackへの内部警告だけで終わり、顧客の画面やメールには何も変化がなかった。 2026-07-23に直した範囲は狭い。メール重複の判定自体や、既存のAuthユーザーを調べて再利用する処理は変えていない。変えたのは、この失敗を顧客にも知らせるかどうかを呼び出し元が選べるようにし、契約署名フック経由の自動発行だけでそれを有効にしたことだけだ。

症状

  • 契約が署名されると、入稿用の顧客アカウントをSupabase Authへ自動発行する(provisionCustomerAccount)
  • 発行に使うメールアドレスで、すでにSupabase Auth側にユーザーが存在する場合、createUserはemail_existsで失敗し、関数はreason: "auth_exists"を返す
  • 従来、この分岐はnotifySlackで内部向けの警告を送るだけで、顧客側には何のメールも届かず、画面にも変化がなかった
  • 顧客からは、契約に署名したあと入稿アカウントの案内が来るのを待つだけで、実際には何も進んでいない状態がそのまま続いた

原因

provisioning.tsの冪等チェックはprofilesテーブルだけを見て、Auth側のユーザーの生死は見ない。コード自身のコメントがその設計判断を明言している。

// provisioning.ts(e5fe39eaf5, 118-123行)
// 1. 冪等チェック: 1社1アカウント(§2.4 = company_id × role × is_active)。
//    ロールごとに数える(広告主の customer と団体の group_manager は別アカウント)。
//    無効化済み(is_active=false)の旧アカウントが残るケースは「再発行を試みる」が、
//    同一メールの Auth ユーザーが生きていれば後段で email_exists → auth_exists で
//    Slack 警告+手動に落ちる(多重発行はしない / §2.6)。

つまりこの関数は、profilesに有効な行がなければ再発行を試みるように意図的に作られている。だがAuth側に同じメールのユーザーがまだ残っていれば——それが本人の過去の発行の残骸でも、無関係な別人の既存アカウントでも——createUserは同じemail_existsで失敗し、関数は両者を区別せず同じauth_existsを返す。

// provisioning.ts(e5fe39eaf5, 148-178行)
if (createErr || !created?.user) {
  if (isEmailExistsError(createErr)) {
    // B-1(2026-07-22 E2E で実発生): 同一メールの Auth ユーザーが既存だと発行できず、
    // 従来は Slack 警告のみ=顧客には何も届かない無音の穴だった。
    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 };
}

コードコメントが明記する通り、E2Eで見つかった時点ではこのnotifyCustomerOnConflictという分岐自体が存在せず、auth_existsは常にSlack警告だけで終わっていた。なお、profilesへのINSERTが失敗した場合はAuthユーザーを削除してロールバックする処理もあるが、

// provisioning.ts(e5fe39eaf5, 184-196行)
if (profErr) {
  await admin.auth.admin.deleteUser(created.user.id);
  return { ok: false, reason: "error", detail: profErr.message };
}

このdeleteUserの戻り値は見ていない。削除自体が失敗すれば、profilesの行を持たない「孤児」のAuthユーザーが残り、それ以降の再発行は常に同じauth_existsの分岐を通ることになる。

直す

2026-07-23、ProvisionInputにnotifyCustomerOnConflictという引数を追加し、auth_exists発生時に顧客への案内メールを送るかどうかを呼び出し元が選べるようにした。

// provisioning.ts(e5fe39eaf5, 38-49行)
/**
 * auth_exists(同一メールの Auth ユーザー既存)のとき顧客へ案内メールを送るか(B-1)。
 * 自動レール(契約署名フック)だけ true。手動発行(/admin/users・団体配分)は画面で
 * 結果が見え staff が対処できるため送らない(リトライのたびの重複送信も防ぐ)。
 */
notifyCustomerOnConflict?: boolean;

契約署名フック(contract-signed.ts)はこれをtrueで呼び、Slack警告に加えて顧客にもメールを送るようにした。

// contract-signed.ts(f1705230, 99-114行)
const r = await provisionCustomerAccount({
  companyId,
  // 自動レールでは auth_exists でも顧客を無音にしない(B-1・案内メールを送る)。
  notifyCustomerOnConflict: true,
});
// ...
: r.reason === "auth_exists"
  ? `⚠️ 入稿アカウント発行不可: メール ${escapeSlack(r.email ?? "?")} が既存ユーザーと衝突。` +
    `顧客への案内メールは${r.customerNotified ? "送信済み" : "送信失敗⚠"}。` +
    `別メールアドレスで ${usersLink} から手動発行してください ${link}`

一方、手動発行の経路(/admin/users、団体への配分)はこのフラグを渡さず、既定のfalseのままにした。手動経路はstaffが画面でその場で結果を見て対処できるため、ここで顧客にもメールを送るとstaffが同じ操作を再試行するたびに同じ案内メールが重複して届いてしまう。これが、直した範囲を「経路を限定する」という狭い言い方にしている理由だ。

auth_existsが起きたときの実際の落とし先も変わっていない。Slackメッセージは今も「別メールアドレスで手動発行してください」と案内するだけで、衝突した既存のAuthユーザーを調べて再利用する経路は無い。

一般化できる形

「存在確認が失敗したとき、その存在が誰のものかを区別する」コストは、ほとんどの実装で後回しにされる。この関数のコメントが示す通り、設計した側は「profilesに有効な行が無ければ再発行を試みるが、Auth側が生きていればSlack警告+手動に落とす」という判断を意図的に下している。多重発行や、別会社・別スタッフへの誤った紐付けを避けるためには正しい判断だが、その代償として「衝突の原因が本人の過去の失敗なのか、無関係な第三者なのか」は一切調べない作りになっている。

今回直した範囲が「顧客に何かを伝える」ところまでで、「衝突の原因を特定する」ところまで踏み込んでいないのは、この判断そのものを変えていないからだ。同一メールでauth_existsを踏んだ顧客に、システムが案内できるのは「既存のログインを試すか、別のメールアドレスで発行し直してもらう」ことだけで、どちらにしても本人が過去にこのシステムで何をしたかはシステム側からは答えられない。存在確認の失敗をどこまで誰に通知するかを決める前に、その失敗が何を意味しているのかを区別できる作りになっているかを、先に確認したほうがいい。

よくある質問

Q1メール重複(email_exists)でアカウント発行が止まると、顧客にはどう見えますか?

契約に署名した直後、入稿用アカウントを自動発行する処理が、同じメールアドレスのAuthユーザーが既に存在すると失敗します。従来はこの失敗がSlackへの内部警告だけで終わり、顧客の画面やメールには一切変化がありませんでした。顧客からは『申し込んだのに何も進んでいない』としか見えない状態でした。

Q2直したのは、メール重複そのものを解消する処理ですか?

いいえ。メール重複(email_exists)が起きたときの判定や、既存のAuthユーザーを調べて再利用する処理は変えていません。変えたのは、その失敗を顧客にも知らせるかどうかを呼び出し元が選べるようにした点だけです。契約署名フック経由の自動発行だけがこの通知を有効にしています。

Q3なぜ手動発行の経路では、顧客への案内メールを送らないのですか?

手動発行(/admin/usersや団体への配分)はstaffが画面でその場で結果を見て対処できるため、ここで顧客にもメールを送ると、staffが同じ操作を再試行するたびに同じ案内メールが重複して顧客に届くことになります。自動レールは人が画面を見ていないぶん、顧客への通知が唯一の手がかりになるため、そちらだけ有効にしています。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2 / React 19.2.4
  • 2026-07-22 E2EでAuth発行の無音の穴が発覚・2026-07-23に経路限定で修正

この記事の根拠

  • TypeScriptファイル 38〜49行目コミット e5fe39e
  • TypeScriptファイル 118〜182行目コミット e5fe39e
  • TypeScriptファイル 184〜196行目コミット e5fe39e
  • TypeScriptファイル 90〜120行目コミット f170523

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。