安全的 Stripe Checkout Edge Functions:從建立 Session 到 webhook 入帳
用 Supabase Edge Functions 將 Checkout 建立、使用者驗證、可信價格、Stripe webhook 簽章與冪等入帳拆成清楚邊界。
安全付款流程必須把三種「成功」分開:瀏覽器成功導向、Stripe 確認付款、內部 entitlement 或訂單入帳。只有第二種透過已驗證事件進入第三種,才可授予課程或會員權限。success URL 是使用者體驗,不是付款證據;它可能被刷新、直接開啟,也可能在付款完成前關閉。 截至 2026-07-29,Stripe Checkout Sessions 官方文件仍要求由 server 建立 Session;Stripe webhook 文件要求以原始 request payload、 Stripe-Signature 與 endpoint secret 驗證簽章,並處理重複事件。Supabase 官方將 Edge Functions 列為 webhook 與 Stripe 整合用途,且預設 function 會驗證 JWT;Stripe webhook 沒有使用者 JWT,因此只有該 endpoint 要明確關閉平台 JWT gate,然後以 Stripe signature 取代它。 實作步驟 先拆成兩個 function: create-checkout-session 接受已登入使用者 request, stripe-webhook 接受 Stripe 公開呼叫。不要讓一個 endpoint 同時支援兩種身份模型。第一個保留 JWT 驗證;第二個在 config.toml 設 verify_jwt = false ,但 handler 第一件事就是驗證 Stripe signature。 [functions.create-checkout-session] verify_jwt = true [functions.stripe-webhook] verify_jwt = false 建立 Checkout 時,client 只能提交內部 product_id 或 plan key,不可提交可信任的金額、currency、Stripe Price ID、customer ID 或 success URL。function 從資料庫 allowlist 取出目前有效的產品與 server-owned Stripe Price ID,再核對登入者是否可購買。redirect origin 也來自 server allowlist,不可回傳任意 caller origin。 在呼叫 Stripe 前先建立內部 pending order,並產生與該 order 綁定的 idempotency key。Session 的 client_reference_id 或 metadata 保存不可猜測的 order ID,讓 webhook 能回查。secret key 只能存在 Supabase project secret,不可使用 VITE_ 前綴、不可回傳 client、不可寫入 log。 以下為概念性 Edge handler;實際 import 與 API 版本必須依當下 Stripe/Supabase 官方 quickstart 鎖定並測試: Deno.serve(async (req) => { const user = await requireAuthenticatedUser(req) const { productId } = await req.json() const product = await loadActiveProduct(productId) if (!product) return Response.json({ error: "invalid_product" }, { status: 400 }) const order = await createPendingOrder(user.id, product) const session = await stripe.checkout.sessions.create({ mode: product.mode, line_items: [{ price: product.stripe_price_id, quantity: 1 }], client_reference_id: order.id, success_url: `${ALLOWED_APP_ORIGIN}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${ALLOWED_APP_ORIGIN}/checkout/cancelled`, }, { idempotencyKey: `checkout:${order.id}` }) return Response.json({ url: session.url }) }) 不要在 response 送出 Stripe secret 或完整 Session object,只回 client 真正需要的 URL/ID。CORS header 使用精確 allowlist;OPTIONS 可以公開回應,但實際 POST 仍需 JWT。建立 Session 前做輸入長度、JSON shape、產品狀態與 rate limit 檢查。 webhook handler 要先取得 await req.text() 的原始 body,再取得 Stripe-Signature 。先驗簽,通過後才 JSON.parse 或交給 SDK constructEvent。若先把 JSON parse 後重新 stringify,bytes 可能改變,造成合法簽章失敗。驗證失敗回 400,不得繼續使用 service-role 寫 DB。 驗證成功後,在一個 database transaction 或受 constraint 保護的 RPC 中插入 Stripe event ID。event ID unique conflict 代表重送,安全回 2xx 而不再次授權。只處理 allowlist event types;例如 Checkout 完成後仍依實際付款模式與事件資料確認需要的狀態,不能只看任意 event 名稱。order amount、currency、Stripe customer/reference 必須與 server 建立時的預期資料核對,再把 order 從 pending 移到 paid 並授予 entitlement。 service-role key 只存在 webhook server context,因為它可繞過 RLS。RPC 要縮小到明確的狀態轉移,檢查目前 order 狀態、provider reference 與 event 去重;不要提供「傳 user ID 就能新增課程」的通用 admin function。 失敗與復原 建立 Session timeout 時,不要立即用新 key 建第二個 Session。以相同 order/idempotency key 重試,或先查 internal order 是否已有 provider session。若產品或價格已改,關閉舊 pending order,建立新的 order 與新 key,保留兩者關係以供 audit。 webhook 回 401 通常表示忘記針對該 function 關閉 Supabase JWT gate;但修復不能把所有 function 全部設成 public。只調整 stripe-webhook ,並確認 signature verification 測試在最前面。回 400 signature mismatch 時,檢查 endpoint secret 是否屬於目前 endpoint、是否把原始 body 改寫、CLI 與 dashboard secret 是否混用。 若 DB 暫時不可用,webhook 應回非 2xx 讓 Stripe 依平台機制重試,handler 本身保持冪等。不可先回 200 再以未持久化的記憶體工作處理。若事件已記錄但 entitlement transaction 失敗,保存 processing/failed 狀態與錯誤分類,讓受控 worker 重試同一事件。 若錯誤授權已發生,先停用有問題的 checkout product/feature flag,保留 event、order 與 log,不要刪除證據。依 verified provider state 執行修復 transaction,再用 audit query 列出所有受影響 entitlement。secret 可能外洩時立即輪替 Stripe/Supabase secret,並確認舊部署不再持有。 驗證指令 本地以 Supabase CLI 啟動 function,使用 Stripe CLI 轉送測試事件。下列名稱需符合實際 project: supabase functions serve create-checkout-session supabase functions serve stripe-webhook --no-verify-jwt stripe listen --forward-to http://127.0.0.1:54321/functions/v1/stripe-webhook 驗收要覆蓋:匿名呼叫 create function 被拒絕;偽造 product/price/redirect 被拒絕;webhook 缺 signature 或 body 被修改時不寫 DB;同一 event 送兩次只產生一筆 event 與一次 entitlement;兩個並行 handler 結果相同;success URL 單獨開啟不會授權;退款/取消事件依業務規則更新;log 不含 API key、token、完整付款資料。 官方來源 Stripe Checkout Sessions Stripe webhook 文件 Supabase Edge Functions Supabase Function Configuration 延伸閱讀 從 技術文章 串接付款 order migration 與 Auth 邊界。 在 課程總覽 練習 Edge Function、RLS 與 webhook 測試。 發現付款流程安全問題時,從 聯絡頁 提供去識別化重現步驟。