Apple の「メールを非公開」で、ユーザーが永久にログインできなくなる
「アカウントが消えた」の正体は Sign 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_enabled | False |
external_accounts | oauth_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 = False と xxxxxxxx@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_enabled と external_accounts だけだった。
サーバー側が全部正常なときこそ、クライアントの導線を疑う。 BAN もロックもされていない、セッションも生きている、データも無事。そこまで確認して「原因が無い」と思ってしまったが、原因はサーバーの外にあった。ログインの失敗はサーバーのログに残るが、そもそもログインを試せていない失敗はどこにも残らない。「ログが無い」は「問題が無い」ではなかった。