実際のリポジトリを拝見する前に、Next.js × Supabase × Stripe の試作でよく見かける5つの落とし穴を、Before / After でまとめました。用語はできるだけかみくだいて書いています。レビューは「ダメ出し」ではなく、一緒に安全にしていく作業です。
export async function POST(req) { const event = await req.json(); // 生のJSONをそのまま信用 if (event.type === 'checkout.session.completed') { await confirmBooking(event.data.object.metadata.lessonId); } return Response.json({ received: true }); }
const sig = req.headers.get('stripe-signature'); const body = await req.text(); // rawボディが必要 let event; try { event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET); } catch { return new Response('bad signature', { status: 400 }); }
なぜ危険? 署名を確認しないと、誰でも「支払いが成功しました」という偽のリクエストを送って、無料でレッスンを予約できてしまいます。Stripe から来たことを暗号的に確認する constructEvent が必須です。Next.js の App Router では、ボディを req.text() で生のまま受け取る点にも注意します。
// /api/checkout const { amount, teacherId } = await req.json(); const session = await stripe.checkout.sessions.create({ line_items: [{ price_data: { currency: 'jpy', unit_amount: amount, // ← 画面から来た値 product_data: { name: 'レッスン' } }, quantity: 1 }], mode: 'payment', });
const { lessonId } = await req.json(); const lesson = await supabase.from('lessons') .select('price_jpy, teacher_id, status') .eq('id', lessonId).single(); if (lesson.status !== 'open') throw new Error('not bookable'); const amount = lesson.price_jpy; // ← DBの値だけを使う
なぜ危険? フロントから来る値は改ざんできます。開発者ツールで amount を 1 に書き換えれば1円でレッスンが買えてしまう、という事故が起きます。お金に関わる値は必ずサーバ側でデータベースから取り直すのが鉄則です。
if (event.type === 'payment_intent.succeeded') { await supabase.from('bookings').insert({ lesson_id: pi.metadata.lessonId, status: 'paid', }); await sendConfirmationEmail(pi.metadata.lessonId); }
// Stripeは同じイベントを複数回送ることがある const ins = await supabase.from('webhook_events') .insert({ id: event.id }) // idにUNIQUE制約 .select().maybeSingle(); if (!ins.data) return Response.json({ duplicate: true }); // ここから先は「初回だけ」実行される await upsertBooking(pi.metadata.lessonId);
なぜ危険? Stripe は配信を保証するために、同じ Webhook を2回以上送ることがあります。対策がないと、予約が2件入ったり、講師へ80%が2回送金されたりします。イベントIDを記録して「処理済みならスキップ」する、送金・返金の API 呼び出しには idempotencyKey を必ず付けます。
// components/BookButton.tsx (ブラウザで動くコード) const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY, // ← 全権限キーが公開 ); await supabase.from('lessons').update({ status: 'paid' });
// ブラウザ側は anon キー + RLS で最小権限 const supabase = createClient(URL, ANON_KEY); // 予約確定など重要な更新はサーバ側(API/Edge)だけ // service_role キーは server 環境変数に置き、 // NEXT_PUBLIC_ を付けない const admin = createClient(URL, process.env.SERVICE_ROLE_KEY);
なぜ危険? service_role キーは RLS(行レベルセキュリティ)を無視して何でもできるキーです。ブラウザに置くと、誰でも他人の予約やレッスン料金を書き換えられます。NEXT_PUBLIC_ が付いた環境変数はビルドに埋め込まれて公開される、という点も要チェックです。
await stripe.paymentIntents.create({ ... }); await supabase.from('bookings').insert({ ... }); // ↑ 2行目で失敗しても、決済だけ通ってしまう return Response.json({ ok: true });
// 予約は「仮押さえ→Webhookで確定」の2段階に // 1) 予約を pending で作成(枠をロック) // 2) 決済セッション作成 // 3) payment_intent.succeeded で confirmed に // 4) 一定時間 pending のまま → 自動で解放+返金 await reconcilePendingBookings(); // 定期実行
なぜ危険? 決済とデータベース更新は別のシステムなので、「片方だけ成功」が必ず起こります。Stripe 側の状態を正(ソース・オブ・トゥルース)にして、Webhook で予約を確定し、取り残された決済は定期チェックで返金する。この設計にしておくと、初心者運営でも事故が表に出にくくなります。
・危険度(高 / 中 / 低)を付けた指摘リスト(この5項目のような形式)
・それぞれの「なぜ危険か」「どう直すか」の短い説明
・すぐ直すべき順番の提案
・画面共有での読み合わせ(用語はその場でかみくだいて説明します)