詰まった → 調べた → 分かった

Apple の「メールを非公開」で、ユーザーが永久にログインできなくなる

「アカウントが消えた」の正体は Sign in with Apple のメール非公開だった。サーバー側は全部正常で、犯人は自分が出していた「前回はメールでサインインしました」バッジだった。

Clerk認証iOSSign in with Apple

個人開発の iOS アプリを運営していて、「サインアウトしていないのにアカウントが消えた」という報告が続けて届いた。

結論から書くと、データは一件も消えていなかった。そのユーザーは Sign in with Apple の「メールを非公開」で登録していて、自分の実メールアドレスでは絶対にログインできない状態だった。そして被害を広げていたのは、私が親切のつもりで出していた「前回はメールでサインインしました」というバッジだった。

何が起きたか

3件ほぼ同時期に来た。

  • Aさん(7/16)「サインアウトしていないのにアカウントが消えた」
  • Bさん(7/23)今朝アカウントを作って、4時間後には入れなくなった
  • App Store レビューにも同種の「アカウントが消えた」(7/12)

最初に疑ったのはセッションの寿命だった。この少し前に「7日でセッションが切れる」という別のバグを踏んでいたので、またそれかと思った。だからまずセッション設定を確認しに行った。これが遠回りの始まりだった。

サーバー側は、全部正常だった

Aさんのアカウントを Clerk の Backend API で調べた。

$sk = "sk_live_..."   # Clerk Secret Key
$h  = @{ Authorization = "Bearer $sk" }

$u = Invoke-RestMethod -Uri "https://api.clerk.com/v1/users/USER_ID" -Headers $h
"banned=$($u.banned) locked=$($u.locked) 2FA=$($u.two_factor_enabled) password=$($u.password_enabled)"
($u.external_accounts | % { "$($_.provider)[$($_.verification.status)]" }) -join ", "

返ってきたのはこう。

項目
banned / locked / two_factor_enabledすべて False
password_enabledFalse
external_accountsoauth_apple(verified)1件のみ
メールアドレスxxxxxxxx@privaterelay.appleid.com
セッションActive・有効期限は2036年

BAN もロックもされていない。2段階認証もかかっていない。セッションも生きている。アプリのビルド番号も当時の最新だった。

データベース側も数えた。投稿33件、登録された「うちの子」11匹、どちらも無事。

サーバーは無罪だった。 ここで手詰まりになった。

ちなみにこの「投稿33件も11匹も全部残っています」を数字で言えたことは、技術的には何も解決していないのに、返信としては一番効いた。「消えた」と思っている人の恐怖を消すのは、原因の説明より先に現物の確認だった。

決め手はセッション履歴だった

次にセッションの一覧を時系列で並べた。

$s = Invoke-RestMethod -Uri "https://api.clerk.com/v1/sessions?user_id=USER_ID&limit=20" -Headers $h

見えたのは、7/16 深夜以降、新しいセッションが1つも作られていないという事実だった。

Clerk はサインインが成功すると新しいセッションを作り、古いほうを replaced にする。つまり最新のセッションが active のまま止まっているということは、それ以降サインインが一度も成功していない

ここで問いが変わった。「なぜログアウトされるのか」ではなく、「なぜサインインの試行がサーバーまで届いていないのか」。本人は毎日ログインを試していると言っている。なのにサーバーには痕跡がない。ということは、ログイン画面から先に進めていない。

原因:Apple の「メールを非公開」

password_enabled = Falsexxxxxxxx@privaterelay.appleid.com を並べて見た瞬間に分かった。

Sign in with Apple で「メールを非公開」を選ぶと、Apple はアプリに本人の実アドレスを渡さない。代わりに @privaterelay.appleid.com の転送用アドレスを渡す。Clerk に保存されるのはそっちだ。

その結果こうなる。

  • Clerk に本人の実アドレスは存在しない → メール欄に打っても「そのアカウントはありません」
  • password_enabled が false → パスワードも存在しない
  • 入口は「Appleでサインイン」ボタン1つだけ

そして本人は「メールアドレスで登録した」と思っている。Apple のシートに自分のメールアドレスが表示されるので、そう記憶するのは自然だ。メール欄に打ち込んで、失敗して、また打ち込む。何度やっても永久に入れない。サーバーには何も届かない。

セッション履歴が空だったのは、そういうことだった。

もっと悪かったのは、自分が誤誘導していたこと

ここまでなら「Apple の仕様を知らなかった」で済む話だ。2件目のBさんで、もっとまずいことが分かった。

このアプリのログイン画面には「前回はメールでサインインしました」というバッジを出す機能を付けていた。「どれで入ったっけ?」という迷いを消すつもりだった。

Bさんの画面には、そのバッジが出ていた。Apple で登録した人に。

原因は判定ロジックだった。当時の実装はこうなっていた。

// 旧実装(誤り)
const providers = user.externalAccounts.map((a) => a.provider);
if (providers.includes("apple")) return "apple";
if (providers.includes("google")) return "google";
return "email";  // ← 外部連携が空 = メール、と決めつけていた

外部連携が空なら消去法でメールだろう、という素朴な実装。これが罠だった。

Clerk の useUser() が返す externalAccounts は、非同期で後から埋まる。 ユーザーオブジェクトは先に返ってくるが、連携情報はまだ空配列のことがある。既存ユーザー向けに一度だけ走らせる backfill 処理がちょうどその瞬間に動くと、空配列を「連携なし」と読んで「メール確定」と判定してしまう。

つまり Apple ユーザーに「前回はメールでサインインしました」と表示していた。永久に入れない入口を、こちらから指差していたことになる。

修正はこう。

// 修正後: 「メール」と言い切るのは passwordEnabled が true のときだけ
if (hasApple && !hasGoogle) return "apple";
if (hasGoogle && !hasApple) return "google";
if (hasApple && hasGoogle) return null;   // 複数連携=どれで入ったか不明
if (user.passwordEnabled === true) return "email";
return null;                              // 判定保留=バッジを出さない

ポイントは最後の return null で、分からないときは黙るようにしたこと。空配列は「連携が無い」ではなく「まだ読めていない」かもしれない。両者を区別できない以上、断定してはいけなかった。

あわせて画面自体も変えた。メール欄を既定で畳んで、Apple / Google のボタンを前面に出した。

このとき、新規登録側のほうが深刻だと気づいた。サインイン画面でメールに釣られても、失敗して終わるだけだ。だが新規登録画面でメールに釣られると、Apple で登録済みの人がメールで別アカウントを作ってしまう。そのアカウントには当然、過去の投稿もぬいぐるみも入っていない。これは「消えたように見える」ではなく、本人の体験としては本当にデータが消えた状態になる。

解決

Aさんには「メールアドレスではなく、Appleでサインインの黒いボタンから入ってみてください」と返信した。7/17 の 15:56。

同じ日の 21:46 に、そのユーザーの最終アクセス時刻が更新された。戻ってきていた。

余談だが、この復帰時刻はだいたい予想がついていた。そのユーザーの投稿33件のうち82%が18時から24時に集中していて、ピークが21時台だったからだ。「返信は読まれるとしても夜だな」と思っていたら、その通りの時間に戻ってきた。

調べるときに詰まったところ

本筋ではないが、全部実際に踏んだので置いておく。

  • セキュリティソフトが curl を止める。 手元の Norton が API 呼び出しをブロックしたので、途中から PowerShell の Invoke-RestMethod に切り替えた。
  • PowerShell 5.1 には Get-Date -UnixTimeSeconds が無い(PS7 以降専用)。Clerk が返すのはミリ秒 epoch なので、変換を手書きする羽目になった。
  • 管理用のユーザー検索が直近500件しか走査していなかった。 古い登録ユーザーは絶対にヒットしない。これで一度「そんなユーザーは存在しない」と誤判定した。調査ツールの探索範囲は、疑う対象に入れておくべきだった。
  • 問い合わせメールの差出人アドレスは、登録アドレスとは限らない。 一度これで別人を調べていた。最短経路は、本人にユーザー名とログイン方法を聞くこと。

学んだこと

password_enabled = false かつ外部連携が Apple か Google だけ。この2つを見た時点で「この人はメール欄からは永久に入れない」が確定する。 調査を始める前に、まずここを見ればいい。

本人の記憶も、自分が出した UI も、証拠にならなかった。 ユーザーは「メールで登録した」と言い、アプリは「前回はメールで」と表示していた。両方とも間違っていた。信用できたのは Clerk が持っている password_enabledexternal_accounts だけだった。

サーバー側が全部正常なときこそ、クライアントの導線を疑う。 BAN もロックもされていない、セッションも生きている、データも無事。そこまで確認して「原因が無い」と思ってしまったが、原因はサーバーの外にあった。ログインの失敗はサーバーのログに残るが、そもそもログインを試せていない失敗はどこにも残らない。「ログが無い」は「問題が無い」ではなかった。

← 一覧へ