はじめに
Claude CodeにStripeのWebhookハンドラを書かせると、署名検証が省略されたコードが出てくることがある。コンパイルは通り一見動くように見えるが、攻撃者が偽のWebhookを送れば未払いの注文を完了させられる。冪等性キーの欠落も同様で、ネットワーク障害によるリトライで二重課金が発生する。本記事はClaude Codeが省略しやすい安全側の実装をCLAUDE.mdで強制する方法に絞る。
Webhook署名検証と冪等性を正しく実装する
Webhook署名検証の省略
Claude Codeはリクエストボディをreq.json()でパースするコードを生成しやすいが、これではStripeの署名検証が機能しない。生ボディ(テキスト)を取得してから検証する。
// ❌ req.json()使用(署名検証できない)
const body = await req.json();
const event = body as Stripe.Event; // 任意のリクエストを処理してしまう
// ✅ req.text()で生ボディを取得してから署名検証
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = (await headers()).get('stripe-signature');
if (!sig) return NextResponse.json({ error: 'No signature' }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return NextResponse.json({ error: 'Webhook Error' }, { status: 400 });
}
// 署名検証後にイベント処理
}
署名検証に失敗したときは必ず400を返す。Stripeは200以外をリトライするが、400は最終失敗として記録する仕様だ。
冪等性キーの欠落
stripe.paymentIntents.create()を冪等性キーなしで呼ぶと、ネットワーク障害後のリトライで新しいPaymentIntentが作られて二重課金が発生する。
// ❌ idempotencyKeyなし(リトライで二重課金)
const paymentIntent = await stripe.paymentIntents.create({ amount, currency });
// ✅ 注文IDを冪等性キーに使う
const paymentIntent = await stripe.paymentIntents.create(
{ amount, currency },
{ idempotencyKey: `payment-intent-${orderId}` }
);
冪等性キーは注文IDから導出するか、リクエストスコープのUUID v4を使う。機密情報(メールアドレス等)をキーに含めてはならない。
StripeエラーをClaude Codeに正しく処理させる
Claude Codeはcatch (error)で全エラーを一律に処理するコードを生成しがちだ。カード拒否の理由がユーザーに伝わらず、リトライすべきエラーとすべきでないエラーを区別できなくなる。
// ❌ 全エラーを一律処理
} catch (error) { return { error: '決済に失敗しました' }; }
// ✅ Stripeエラー型を判別して処理
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
const code = error.decline_code;
if (code === 'insufficient_funds') return { error: '残高不足です', retryable: false };
if (code === 'expired_card') return { error: 'カードの有効期限切れです', retryable: false };
// fraudulent・lost_card は具体理由を表示しない(攻撃者への情報提供を防ぐ)
return { error: '決済が承認されませんでした', retryable: false };
}
if (error instanceof Stripe.errors.StripeRateLimitError ||
error instanceof Stripe.errors.StripeConnectionError) {
return { error: 'しばらく後に再試行してください', retryable: true };
}
throw error;
}
リトライ可能なのはStripeRateLimitError・StripeConnectionError・StripeAPIErrorの3種。StripeCardError・StripeInvalidRequestError・StripeAuthenticationError・StripeIdempotencyErrorは再試行しても解決しない。
CLAUDE.mdテンプレート+Stripe CLI
CLAUDE.mdテンプレート
# Stripe統合ルール
## キー管理
- STRIPE_SECRET_KEY等は環境変数から参照(ハードコード禁止・sk_live_はCI/本番のみ)
- .env.localは.gitignoreに含める
## Webhook(必須)
- req.text()で生ボディを取得(req.json()禁止)
- stripe.webhooks.constructEvent()を最初に実行
- 署名検証失敗時はHTTP 400を返す
- 処理済みイベントIDをDBに記録して重複処理を防ぐ
## 冪等性
- stripe.paymentIntents.create()等には必ずidempotencyKeyを渡す
- キーは注文IDから導出(`payment-intent-${orderId}`)またはUUID v4
## エラーハンドリング
- instanceof Stripe.errors.StripeCardErrorでエラー型を判別
- fraudulent・lost_cardはユーザーに具体理由を表示しない
- StripeRateLimitError・StripeConnectionErrorはリトライ可能として処理
## PCI DSS禁止事項
- カード番号・CVV・有効期限をサーバーで受け取るコード禁止
- Payment Element / Checkout Sessionを使いカード情報はStripeに直接送信
Stripe CLI基本コマンド
# ローカルに転送(STRIPE_WEBHOOK_SECRETが出力される)
stripe listen --forward-to localhost:3000/api/webhook
# テストイベントをトリガー
stripe trigger checkout.session.completed
stripe trigger payment_intent.payment_failed
stripe trigger customer.subscription.deleted
EM視点──PCI DSS準拠とチームのStripe標準化
Stripeを使うとPCI DSS準拠スコープが大幅に縮小するが、Webhook署名検証・APIキー管理・ログへのカード情報出力禁止は義務として残る。CLAUDE.mdにPCI DSS禁止事項を書いておけば、Claude Codeが誤ってカード番号を受け取るエンドポイントを生成するリスクを防げる。チームへの展開は既存コードをgrepして違反箇所を洗い出すところから始める。
まとめ
Claude CodeがStripeコードで省略しやすいのはWebhook署名検証・冪等性キー・エラー型判別だ。CLAUDE.mdに本記事のテンプレートを追記すれば、これらを前提にしたコードが生成される。stripe listen --forward-toでローカルWebhookテストを実施して動作を確認してほしい。

コメント