はじめに
🤔「Instagram から来てくれた人はいるのに、登録がほとんど無い…」
😥「自分で試すと、ちゃんとログインできるんだけど…」
🫠「メールのリンクを押したら、エラーでトップに戻された…?」
SNS の広告やプロフィールのリンクからサービスに来てもらうのは、よくある流れです。
ところが、Instagram のアプリの中から来た人は、ログインを完了できないことがあります。
個人開発している口コミサービス あじぴた でも、まさにこれが起きていました📲
Instagram の広告から来てくれた人はいるのに、登録がほとんど無い状態です。
原因は、Instagram のアプリ内ブラウザと、Supabase のログインの仕組みの相性でした。
この記事では、
- アプリ内ブラウザから始めると、メールのリンクでのログインが完了しない理由
- Supabase のメールのリンクを、別のブラウザで開いても完了する形に変えた方法
- その代わりに手放したものと、それでも受け入れた理由
を書きます🔐
この記事は、個人開発サービス「あじぴた」の設計を書くシリーズの 1 本です。
単体で読めるように書いていますが、サービスの全体像はハブ記事にまとめています。
何が起きていたか
Instagram の広告やプロフィールのリンクを押すと、Instagram アプリの中のブラウザでページが開きます。
Safari や Chrome ではなく、アプリに組み込まれたブラウザ(WebView)です。
あじぴたのログイン方法は、メールのリンク(マジックリンク)と Google で続けるの 2 つです。
このうちメールのリンクが、アプリ内ブラウザから始めると完了しませんでした😇
届いたメールのリンクを押すと、エラーでトップページに戻されてしまうのです。
自分で試すと、普段使っているブラウザでは問題なくログインできます。
アプリ内ブラウザから始めたときだけ失敗するので、なかなか気づけませんでした。
原因:メールのリンクが、別のブラウザで開かれる
アプリ内ブラウザでメールアドレスを入力すると、ログイン用のリンクがメールで届きます。
そのリンクを押すと、メールアプリから Safari や Chrome が開きます。
アプリ内ブラウザには、戻ってきません。
Instagram のアプリ内ブラウザ
→ メールアドレスを入力して「リンクを送る」
→ (メールアプリを開く)
→ リンクを押すと、Safari が開く ← ログインを始めたブラウザとは別
→ ログインの完了に失敗する
PKCE は、「始めたブラウザでしか完了できない」仕組み
なぜ、別のブラウザだと失敗するのでしょうか。
Supabase の SSR 向けのライブラリ(@supabase/ssr)は、ログインに PKCE という仕組みを既定で使います。
PKCE では、ログインを始めるときに**秘密の値(code_verifier)**を作り、始めたブラウザの Cookie に保存します。
そして、メールのリンクから戻ってきたとき、その秘密の値と照らし合わせて、ログインを完了させます。
始めたブラウザ(アプリ内ブラウザ): Cookie に code_verifier がある
開いたブラウザ(Safari) : Cookie に code_verifier が無い → 照合できず失敗
これは本来、セキュリティのための仕組みです。
リンクを盗み見た第三者が、別のブラウザで開いてもログインできないようにしています🔒
ただ、アプリ内ブラウザから始めると、本人がリンクを押しても、必ず別のブラウザになります。
本人ですら、ログインを完了できないのです。
正直に言うと、PKCE を使っていることを、意識して選んではいませんでした。
ライブラリの既定の動きで、設計の記録(ADR)を探しても、判断した跡はありませんでした。
直し方:メールのリンクを token_hash で受ける
そこで、メールのリンクの仕組みを変えることにしました。
検討した案は 3 つです。
| 案 | 内容 | 判断 |
|---|---|---|
| a | メールのリンクを token_hash 方式にし、アプリで verifyOtp する |
✅ 採用 |
| b | 6 桁のコードをメールで送り、入力してもらう | 手間が増える。必要になったらリンクと併記を検討 |
| c | PKCE をやめて、implicit フローにする | トークンが URL のフラグメントで返り、サーバーで受け取れない |
案 a は、Supabase が SSR 向けに推奨している手順でもあります。
ユーザーの操作は、今までと何も変わりません(アドレスを入れる → 届いたリンクを押す)👍
仕組み:リンクそのものに、確認用の値を入れる
PKCE では、メールのリンクに入っているのは code で、照合には始めたブラウザの Cookie が必要でした。
token_hash 方式では、リンクに入っている token_hash だけで、ログインを確認できます。
Cookie は要らないので、どのブラウザで開いても完了します。
メールの本文(Supabase のメールテンプレート)は、リンクをこう組み立てるように変えました。
<!-- Before: Supabase が用意するリンク(PKCE の code が返ってくる) -->
<a href="{{ .ConfirmationURL }}">あじぴたにログインする</a>
<!-- After: token_hash を付けて、アプリの確認用のルートへ直接戻す -->
<a href="{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=email">あじぴたにログインする</a>
リンクの行き先として、新しく /api/auth/confirm というルートを作りました。
受け取った token_hash を verifyOtp で確かめて、ログインを完了させます。
// app/api/auth/confirm/route.ts(要点だけ抜き出しています)
export const GET = async (request: NextRequest) => {
const { searchParams, origin } = new URL(request.url)
const tokenHash = searchParams.get('token_hash')
const type = searchParams.get('type') // 'email' / 'email_change' だけを受ける
const supabase = await createClient()
const { data, error } = await supabase.auth.verifyOtp({
token_hash: tokenHash,
type,
})
if (error || !data.user) {
// 期限切れ・使用済みのリンクは、理由を表示するトップへ戻す
return NextResponse.redirect(`${origin}/?error=auth_callback_failed`)
}
// ここからは、これまでのログイン完了の処理と同じ
return NextResponse.redirect(`${origin}/home`)
}
SiteURL ではなく RedirectTo で組み立てる
リンクの先頭を {{ .SiteURL }} にする書き方もよく見かけます。
最初はそうするつもりでしたが、{{ .RedirectTo }} に変えました。
SiteURL は、環境ごとに 1 つの決まった値です。
そのため、ログインを始めたところとは違う URL に戻ってしまうことがあります。
| 場面 |
SiteURL だと |
|---|---|
| Vercel のプレビュー(PR ごとの URL)でログインを始めた | 開発環境の決まった URL に戻ってしまう |
ローカルで localhost:3000 から始めた |
127.0.0.1:3000 に戻り、Cookie が別扱いになる |
RedirectTo は、アプリがログインを始めるときに渡した URL(emailRedirectTo)そのものです。
始めたところと同じ場所に戻ってくるので、プレビューでもローカルでも正しく動きます🎯
RedirectTo は、Supabase の Redirect URLs の許可リストに入っていないと、SiteURL に置き換えられます。
その場合、リンクはトップページに着いてしまい、ログインできません。
Authentication → URL Configuration → Redirect URLs に、/api/auth/confirm が含まれる(ワイルドカードでもよい)ことを、デプロイ前に確かめておきましょう。
手放したもの:「始めたブラウザでしか完了できない」性質
この変更で、PKCE が守っていた性質を 1 つ手放しました。
**token_hash のリンクは、「リンクを持っている人なら、どのブラウザからでもログインできる」**ということです。
これは、意識して受け入れた判断です。理由を整理しておきます。
リンクを盗み見られた場合
PKCE が追加で防いでいたのは、メールの中身を盗み見た第三者が、本人より先にリンクを開く場合です。
ただ、それにはメールボックスに入り込む必要があります。
そこまでされていれば、攻撃者は自分でログインのリンクを申し込み直せるので、PKCE があっても防げません。
もともとマジックリンクは「そのアドレスのメールを受け取れる=本人」という前提の仕組みです。
リンクは 1 回限りで、短時間で無効になります。
他人のリンクを開かされた場合(ログイン CSRF)
もう 1 つ、コードレビューで指摘を受けて検討したものがあります。
攻撃者が自分のアドレスで申し込んだリンクを、被害者に開かせる場合です。
開いたブラウザは、攻撃者のアカウントでログインした状態になります。
被害者が気づかずに口コミを書くと、その内容は攻撃者のアカウントに残ります。
これも、次の理由で受け入れることにしました。
- 被害者に、自分で申し込んでいないリンクを開かせる必要がある
- 開いた先の画面で、他人のアカウントだと分かる(新規ならメールアドレスが、既存ならアイコンやニックネームが攻撃者のものになる)
- 攻撃者が得られるのは、被害者がそのアカウントで新しく入力したものだけ。被害者自身のアカウントやデータには届かない
そして何より、アプリ内ブラウザから来た人が、本人なのにログインできないことの影響のほうが、はるかに大きいと判断しました⚖️
Google ログインは、「通らないかもしれない」として扱う
もう 1 つのログイン方法、Google ログインにも、気になる点がありました。
Google は、アプリに組み込まれたブラウザからの OAuth ログインを、方針として禁止しています。
禁止された環境では、Google の画面で 403 disallowed_useragent が出て、先に進めません。
そこで、アプリ内ブラウザでは Google ログインは通らないものと考えて、対策を入れました。
ところが、後から iPhone の Instagram アプリで実際に試してみると、Google ログインは普通に通りました😳
方針として禁止されているのは事実です。
ただ、実際にどう判定されるかは、アプリや OS、その時々のバージョンによって変わるようです。
つまり、「通る」とも「通らない」とも言い切れません。
そこで、アプリ内ブラウザのときだけ、ボタンの下に案内を出すようにしています。
アプリ内のブラウザでは、Google でログインできない場合があります。
うまくいかないときは、メニュー(「…」)から「ブラウザで開く」を選ぶか、メールリンクでログインしてください。
アプリ内ブラウザかどうかは、User-Agent で判定しています。
// Instagram / Facebook / LINE のアプリ内ブラウザを判定する
const IN_APP_BROWSER_PATTERN = /\bInstagram\b|\bFBAN\/|\bFBAV\/|\bLine\//i
export const isInAppBrowser = (userAgent: string): boolean =>
IN_APP_BROWSER_PATTERN.test(userAgent)
この判定は、案内を出すためだけに使い、Google のボタンは消さずに残しています🛟
もしボタンを消していたら、判定を誤ったときに普通のブラウザの人までログインできなくなりますし、
本当は通る Google ログインまで、こちらから塞いでいたことになります。
案内なら、外れても「余計な一文が出る」だけで済みます。
失敗したときに、何も言わずに戻していた
直している途中で、もう 1 つ見つかったことがあります。
ログインに失敗すると、トップページに ?error=auth_callback_failed を付けて戻していました。
ところが、この値を、どこにも表示していませんでした。
期限切れのリンクを押した人は、何の説明もなく、トップページに戻されていたのです😶
今回の不具合でも、ユーザーから見れば「なぜかログインできない」としか分からなかったはずです。
そこで、失敗して戻ってきたときは、理由を表示するようにしました。
ログインを完了できませんでした。メールのリンクは有効期限が切れているか、すでに使われている可能性があります。お手数ですが、もう一度お試しください。
切り替えの順番
この変更は、アプリのコードと、Supabase のダッシュボードにあるメールテンプレートの 2 か所にまたがります。
テンプレートは自動では反映されないので、手で貼り替える必要があります。
このとき、順番を間違えると、ログインできない時間が生まれます。
| 状態 | 届くリンク | 結果 |
|---|---|---|
| アプリだけ新しい | 旧テンプレートのリンク(code が返る) |
新しいルートが code も受け取るので、従来どおり動く |
| 両方新しい |
token_hash 付きのリンク |
どのブラウザでも完了する |
| テンプレートだけ新しい |
token_hash 付きのリンク |
新しいルートが無いので 404 ❌ |
そこで、アプリを先に出してから、テンプレートを貼り替える順番にしました。
新しいルートには、旧テンプレートから来る code も受け取る互換の処理を入れています。
こうしておけば、貼り替えるまでの間も、ログインは止まりません。
テストで「別のブラウザ」を再現する
「別のブラウザで開いても完了する」ことは、Playwright の e2e テストで確かめています。
別のブラウザは、Cookie を共有しない別の BrowserContext で再現しました。
// ログインを始める側:Instagram の User-Agent を付ける
const started = await browser.newContext({ userAgent: INSTAGRAM_UA })
// リンクを開く側:Cookie を共有しない、まっさらな別のブラウザ
const opened = await browser.newContext()
// started でメールアドレスを入れてリンクを申し込み、
// 届いたリンクを opened で開いて、ログインが完了することを確かめる
そして、このテストが本当に違いを見分けられるかも確かめました🧪
テンプレートを一時的に旧方式({{ .ConfirmationURL }})に戻すと、
- 同じブラウザで開くテストは、通る
- 別のブラウザで開くテストは、落ちる
となりました。直した仕組みがなければ落ちるテストになっていることを、実際に確かめています。
結果
本番に反映したあと、Instagram のアプリ内ブラウザから実機で確かめました。
- メールのリンクを押すと Safari が開き、既存のアカウントでログインできた
- 登録していないアドレスでも、新規登録の画面まで進めた
まとめ
- Instagram などのアプリ内ブラウザから始めたログインは、メールのリンクが別のブラウザで開かれる📲
-
@supabase/ssrの既定の PKCE は、始めたブラウザの Cookie が必要なので、本人でも完了できない - メールのリンクを token_hash +
verifyOtpに変え、{{ .RedirectTo }}で始めた場所に戻す - Google は方針としてアプリ内ブラウザを禁止しているが、実際は通ることもある。判定でボタンは消さず、案内だけ出す🛟
「自分の環境では動く」は、ユーザーがどこから来るかまでは保証してくれません。
どこからサービスに入ってくるのかを一度たどってみると、見えていなかった入口が見つかるかもしれません💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
これまでに公開した記事です。
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
🔗 Next.js 16 で Web Vitals を測って直した実録!loading.tsx で LCP が 2 倍になった罠と関数リージョン
🔗 「全部見せるためのRLS」は書かない。Supabaseで管理画面だけRLSをバイパスした理由
🔗 ディレクトリ構成は「AIへの指示書」になる。Next.js App Routerで自分の設計論を答え合わせした話
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由
🔗 RLSが壊れてもテストは緑のまま。漏れても気づけない非公開データを、pgTAPで「落ちるテスト」にする
🔗 CI全緑でも、マージしてはいけないPRがあった。GitHub Actionsは「どこで止めるか」で設計する
🔗 新人が初日に迷うことは、AIも毎回迷う。だから暗黙のルールを、すべて言葉にした
🔗 「失敗した」より「何もしていない」が怖い。Supabaseのpg_cronとpg_netで作る、静かな失敗に気づく監視
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏