Stripe決済実装でやりがちな「なりすましアップグレード」の脆弱性と修正
SaaS開発で欠かせない決済機能。Stripeを使えば簡単にサブスクリプション機能を実装できますが、「誰が」 決済しようとしているのか、正しく検証できていますか?
今回のセキュリティ診断で見つかった、Checkout Session作成APIにおける「なりすまし」脆弱性と、その修正アプローチを共有します。
問題:なりすまし脆弱性の発見
脆弱な実装 vs 安全な実装の比較
以下の表で、脆弱な実装と安全な実装の違いを確認してください。
| 項目 | 脆弱な実装 | 安全な実装 |
|---|---|---|
| ユーザーID取得元 | request.body.userId | JWT検証後のuser.id |
| 認証方法 | なし(リクエストボディを信頼) | Authorizationヘッダー検証 |
| 改ざん可能性 | 高(攻撃者が任意のIDを指定可能) | 不可(サーバー側でトークン検証) |
| 攻撃時の影響 | 他人のアカウントに課金可能 | 認証エラーで拒否 |
脆弱なコード例
以下のような実装、心当たりはありませんか?
typescript
// ❌ 脆弱な実装例
export const POST = async ({ request }) => {
const body = await request.json();
// フロントエンドから送られてきたIDをそのまま信じている!
const userId = body.userId;
const userScore = body.score;
const session = await stripe.checkout.sessions.create({
// ...
client_reference_id: userId, // ここが問題
});
return new Response(JSON.stringify({ url: session.url }));
};
このコードの致命的な問題は、「フロントエンドから送られてくる userId は改ざん可能である」 という点です。
詳細はStripe Checkout Session API公式リファレンスおよびOWASP A07:2021 Identification and Authentication Failuresを参照してください。
攻撃シナリオ
- 攻撃者が自分のアカウントでログインし、アップグレードボタンを押す。
- ネットワークリクエストをキャプチャし、
userIdを 「被害者のID」 に書き換えて送信する。 - サーバーは疑いもせず被害者のIDでCheckout Sessionを作成。
- 攻撃者が支払いを完了すると、Webhookが「被害者のID」に対して「支払い完了」イベントを処理する。
- 結果:攻撃者が支払ったのに、被害者のアカウントがProプランになる(あるいはその逆)。
より深刻なのは、userId に適当な値を入れたり、管理者IDを入れたりしてシステムを混乱させることです。
解決策:JWTによる認証
「誰が」リクエストしてきたかは、リクエストボディではなく、認証トークン(JWT) から判断すべきです。
サーバーサイド実装
Supabase Authを使っている場合の修正例を見てみましょう。
typescript
import { createClient } from '@supabase/supabase-js';
export const POST = async ({ request }) => {
// 1. Authorizationヘッダーのチェック
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
console.error('Missing or invalid Authorization header');
return new Response('Unauthorized', { status: 401 });
}
// 2. サーバーサイドでトークンを検証してユーザーを取得
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: authHeader } },
});
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
console.error('Token validation failed:', error);
// 不正アクセス試行をログに記録(本番環境ではモニタリングツールに送信を推奨)
// await logSecurityEvent({ type: 'invalid_token', ip: request.headers.get('x-forwarded-for') });
return new Response('Invalid Token', { status: 401 });
}
// 3. 検証済みのユーザーIDを使用する
// body.userId は一切見ない!
const userId = user.id;
const email = user.email;
const session = await stripe.checkout.sessions.create({
// ...
client_reference_id: userId, // 安全!
customer_email: email,
});
// ...
};
詳細はSupabase Auth getUser() ドキュメントを参照してください。
プロダクション環境での追加考慮事項
- エラーログの記録: 不正なトークンでのアクセス試行を記録し、異常検知に活用
- レート制限: 同一IPからの連続した認証失敗を制限(例:1分間に5回まで)
- アラート通知: 大量の認証失敗が発生した場合、管理者に通知
フロントエンド修正
これに合わせて、フロントエンド側も「自分のID」を送るのではなく、「アクセストークン」を送るように修正が必要です。
typescript
// フロントエンド修正例
const { data: { session } } = await supabase.auth.getSession();
await fetch("/api/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
// トークンをヘッダーに乗せる
"Authorization": `Bearer ${session.access_token}`
},
body: JSON.stringify({
// userIdはもう送らない
language: "ja"
}),
});
まとめ
- 「ユーザー入力(リクエストボディ)はすべて疑え」 はセキュリティの基本。
- ID情報は改ざんやなりすましが容易なため、必ず信頼できる認証基盤(JWT検証など)からサーバーサイドで取得する。
- StripeやSupabaseを使っていても、つなぎ込み部分の実装ミスで脆弱性は生まれる。
- エラーハンドリング、ログ記録、レート制限などの防御層を組み合わせることで、より堅牢なシステムを構築できる。
決済機能は特にお金が絡む部分なので、慎重すぎるくらいの実装を心がけたいですね。