メインコンテンツへスキップ
← 記事一覧に戻る
·開発·11 min read

Next.js + Supabase magic link が1ヶ月壊れていた — auth callback Cookie 落とし穴と smoke test 設計

Next.jsSupabase認証バグ個人開発

結論:NextResponse.redirect() は Cookie を引き継がない

Next.js App Router の Route Handler で、await cookies() でセッション Cookie を書いた後に NextResponse.redirect() を返すと、その Cookie が redirect レスポンスに乗りません。

修正は 1 箇所だけです。

// ❌ これが壊れている(Cookie が redirect に乗らない)
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
await cookies().set('session', ...)  // ← この set が redirect に届かない
return NextResponse.redirect(next)

// ✅ これが正解(redirect レスポンス自体に Cookie をセット)
const response = NextResponse.redirect(next)
const supabase = createServerClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  {
    cookies: {
      getAll: () => request.cookies.getAll(),
      setAll: (cookies) =>
        cookies.forEach(({ name, value, options }) =>
          response.cookies.set(name, value, options)
        ),
    },
  }
)
await supabase.auth.exchangeCodeForSession(code)
return response

この仕様に気づくまで、筆者の masatoman.net では launch 日(2026-04-02)以来、誰もログインできない状態が約1ヶ月続いていました。

masatoman.net(Next.js + Supabase Auth + Stripe)を運用中。magic link バグは 2026-05-01 に発見・修正済み。smoke test を main push 毎 + 6h cron で自動実行する設計に切り替えました。有料記事を2本公開・月額サブスク運用中。このバグが存在した期間、有料コンテンツへの到達そのものが不可能な状態でした。


なぜ1ヶ月気づかなかったのか

サブスク動作確認でたまたま発覚

2026-05-01、Stripe の月次課金が正常に動いているか確認しようとして自分でログインを試みました。magic link メールは届く。リンクをクリックする。しかし、ダッシュボードに到達できない。 ループしてトップページに戻されるだけです。

調べると、Stripe Webhook の失敗ログが 14 件溜まっていました。auth callback の失敗と合わせると、収益化インフラが二重で機能していなかったことになります。

なぜ気づけなかったか

理由は単純です。smoke test がなかった。

  • ログイン画面は表示される
  • magic link メールは届く
  • callback URL に対してアクセスログはある(HTTP 302)
  • Vercel のデプロイは毎回 success

302 リダイレクト自体は成功しているので、エラーログには何も出ません。Cookie がセットされていないことは、実際にブラウザでセッションを確認しなければ分かりません。

# Stripe Webhook 失敗ログを見ると全部 301 リダイレクト
# これも別の落とし穴(naked → www リダイレクト問題)
# auth callback は 302 で成功に見えてセッションは未確立

原因の詳細:Next.js App Router の Cookie 仕様

Route Handler での Cookie 書き込みの制約

Next.js App Router の Route Handler では、next/headerscookies() でセットした Cookie は そのリクエストのレスポンスに直接セットされます。

しかし NextResponse.redirect()新しいレスポンスオブジェクト を生成します。つまり:

  1. cookies().set('session', value) → 元のレスポンスに Cookie をセット
  2. return NextResponse.redirect(url) → 新しいレスポンスを返す(Cookie なし)

元のレスポンスで書いた Cookie は、redirect レスポンスには 引き継がれません。

Supabase SSR 公式パターンとのズレ

Supabase の @supabase/ssr パッケージが推奨する auth callback の実装では、createServerClientcookies.setAll コールバックを渡し、redirect レスポンス自体に Cookie をセットするパターンを採用しています。

// app/auth/callback/route.ts(正しい実装)
import { NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/'

  if (!code) {
    return NextResponse.redirect(`${origin}/auth/error`)
  }

  const response = NextResponse.redirect(`${origin}${next}`)

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) => {
            request.cookies.set(name, value)
            response.cookies.set(name, value, options)  // ← redirect に直接セット
          })
        },
      },
    }
  )

  const { error } = await supabase.auth.exchangeCodeForSession(code)

  if (error) {
    return NextResponse.redirect(`${origin}/auth/error`)
  }

  return response
}

ポイントは response.cookies.set() です。cookies() ではなく、redirect() で作ったレスポンスオブジェクトの .cookies に直接書き込みます。


同時期に発生していた Stripe Webhook の 301 問題

auth callback とは別に、Stripe Webhook も 1 週間全失敗していました。

原因:

  • Stripe に登録した Webhook URL が https://masatoman.net/api/stripe/webhook(naked ドメイン)
  • Vercel が https://www.masatoman.net/... に 301 リダイレクト
  • Stripe の Webhook は POST → 301 を追わない(body 喪失のセキュリティ仕様)
  • 結果:全リクエストが 301 で失敗、Stripe ダッシュボードに 14 件の失敗ログ
# Stripe Webhook の失敗レスポンス(実物)
{
  "redirect": "https://www.masatoman.net/api/stripe/webhook",
  "status": 301
}

修正は Stripe ダッシュボードで Webhook URL を www. 付きに変更するだけ。即座に 200 OK {"received": true} で復旧しました。

auth callback の Cookie 問題とこの Stripe 301 問題が 同時に存在していたため、launch から約1ヶ月、収益化インフラは事実上機能していませんでした。


再発防止:smoke test の設計

なぜ smoke test が必要か

個人開発では QA チームも staging 環境の常時監視もありません。「デプロイが成功した」=「機能している」という誤解をしやすい。

しかし auth callback のような 302 で成功に見える失敗 は、型チェックも linter も検知できません。

実装した smoke test

// app/api/health/auth-callback/route.ts
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { origin } = new URL(request.url)

  // 不正 code で callback を叩いてレスポンスコードを確認
  const res = await fetch(
    `${origin}/auth/callback?code=smoke_test_invalid_code`,
    { redirect: 'manual' }
  )

  // 正常系: error ページへ 302 リダイレクト(Cookie 問題があると無限ループ)
  // 異常系: 500 または予期しない URL へのリダイレクト
  const location = res.headers.get('location') ?? ''
  const ok =
    res.status === 302 &&
    (location.includes('/auth/error') || location.includes('/'))

  return NextResponse.json(
    { ok, status: res.status, location },
    { status: ok ? 200 : 500 }
  )
}
# .github/workflows/smoke-test.yml
name: Smoke Test

on:
  push:
    branches: [main]
  schedule:
    - cron: '0 */6 * * *'   # 6時間ごと

jobs:
  auth-callback:
    runs-on: ubuntu-latest
    steps:
      - name: Auth Callback Health Check
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            https://masatoman.net/api/health/auth-callback)
          if [ "$STATUS" != "200" ]; then
            echo "FAIL: auth callback smoke test returned $STATUS"
            exit 1
          fi
          echo "OK: auth callback smoke test passed"

main push のたびに自動実行。失敗すれば GitHub Actions が赤くなります。

Stripe Webhook の監視

Stripe ダッシュボードの Webhook セクションに 失敗アラートメール設定があります。これを有効化しておくだけで、301 問題のような「配信はされているが失敗している」ケースを即検知できます。


「で、どう稼ぐ?」— 運用品質は収益の前提条件

バグが存在した期間の機会損失

auth callback が壊れていた約 1 ヶ月間、有料記事ページには到達できても 購入ボタンを押せない状態でした。Stripe Checkout は auth セッションが必要なため、ログインできない=購入できないです。

これは「転換率が低い」のではなく「購入経路そのものが存在しない」状態です。

収益化インフラのチェックリスト

個人開発で月収を上げようとする前に、以下が機能していることを確認してください。

チェック項目確認方法
magic link ログインが完了するブラウザで実際にログインしてセッション確認
Stripe Webhook が 200 を返すStripe ダッシュボードの Webhook ログ
決済完了後に正しいページに遷移するStripe テストモードで実際に購入
認証が必要なページが保護されているログアウト状態でアクセス

実際に動作するまで、集客に使うリソースはゼロにするのが正しい順序です。

smoke test を書くコストと価値

auth callback の smoke test は 30 行程度で書けます。GitHub Actions の無料枠(月 2000 分)で 6 時間ごとに実行しても、1 回 1 分未満なら月 120 回 × 1 分 = 120 分消費。無料枠の 6% 以下です。

個人開発において「壊れているのに気づかない期間」は機会損失だけでなく、SEO や口コミにも影響します。smoke test は保険ではなく 収益インフラの一部として設計してください。


まとめ:やること3つ

  1. auth callback の Cookie 実装を確認するcookies().set() で書いた後に NextResponse.redirect() を返していたら今すぐ修正
  2. Stripe Webhook URL が www/naked どちらか確認する — Vercel のデフォルトリダイレクト先と一致させる
  3. smoke test を main push に組み込む — デプロイ成功 ≠ 機能している を前提に設計する

masatoman のメルマガ — 毎週月曜の朝に手紙を 1 通

masatoman.net の今週の記事 1 本を、読者目線で深掘りした手紙が毎週月曜 9:00 に届きます。「これ自分のことだ」が見つかる予告編。登録特典に「個人開発の収益化チェックリスト 15 項目」。

masatoman のメルマガ — 毎週月曜の朝に 1 通

masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。

Next Step

次に読むならこの導線です

すべての記事を見る
有料で次へ進む¥1,000

【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード

Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。

次の実験記録も追う

Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。

masatoman のメルマガ — 毎週月曜の朝に 1 通

masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。

この記事が役に立ったらシェア