mcpField
P
payment-security-clerk-billing-stripe

payment-security-clerk-billing-stripe

github.com/harperaaセキュリティ19182 回インストール45 週間前に更新
リポジトリ

payment-security-clerk-billing-stripe について

payment-security-clerk-billing-stripeは、Clerk BillingとStripeによるサブスクリプション決済のセキュリティスキルです。カード情報をアプリのサーバーに通さない構成や、サブスクリプションのアクセス確認、検証済みWebhookについて説明します。

主な機能

  1. 01Clerk BillingとStripe Checkoutのフロー
  2. 02カード情報をアプリサーバーに通さない構成
  3. 03サーバー側のサブスクリプション確認
  4. 04Webhook署名検証のガイダンス
  5. 05サブスクリプションのライフサイクル処理

活用シーン

  1. 01サブスクリプション決済の設定
  2. 02契約状況に応じたプレミアム機能の制限
  3. 03契約変更Webhookの処理
  4. 04カード情報の直接取り扱いの回避

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/harperaa/secure-claude-skills --skill payment-security-clerk-billing-stripe

インストール後、Claude Code で/payment-security-clerk-billing-stripeを入力して読み込まれたか確認します。

詳細

決済セキュリティ - Clerk Billing + Stripe

決済を直接処理しない理由

PCI-DSS 準拠要件

クレジットカード情報を保存、処理、または送信する場合、Payment Card Industry Data Security Standard (PCI-DSS) に準拠する必要があります。要件には以下が含まれます。

  • 年次セキュリティ監査($20,000-$50,000)
  • 四半期ごとの脆弱性スキャン
  • セキュアなネットワークアーキテクチャ
  • カード会員データの暗号化
  • アクセス制御対策
  • 定期的なセキュリティテスト

小規模企業: 84% が初回の PCI 監査に不合格

継続的なコンプライアンス費用: 年間 $50,000-$200,000

実際に起きた決済処理の失敗事例

Target の情報漏えい(2013 年): 決済情報を保存し、セキュリティ対策が不十分だったため、4,100 万件のカードアカウントが侵害されました。 和解金:$18.5 million

Home Depot の情報漏えい(2014 年): 5,600 万件のカード情報が盗まれました。カード情報をローカルに保存していました。 和解金:$17.5 million

安全な方法:カード情報を決して扱わない

Clerk Billing + Stripe を使用すれば、クレジットカード情報を閲覧、保存、送信することは一切ありません。そのため、PCI-DSS の対象にはなりません。対象となるのは Stripe です。

決済アーキテクチャ

起こること(起こらないこと)

ユーザーがサブスクリプションに登録する場合:

  1. フロントエンドに Clerk の PricingTable コンポーネントを表示します
  2. ユーザーが登録をクリック → Clerk が Stripe Checkout を開きます
  3. ユーザーがカード情報を入力 → Stripe のサーバー(当社のサーバーではありません)
  4. Stripe が決済を処理 → Stripe のサーバー(当社のサーバーではありません)
  5. Stripe が Clerk に通知 → Webhook(Clerk が検証)
  6. Clerk がサブスクリプションのステータスを更新
  7. Clerk が Convex に通知 → 当社のデータベースへの Webhook
  8. アプリがサブスクリプションのステータスを読み取り → アクセスを許可
当社のサーバーに届くことのない情報
  • ❌ クレジットカード番号
  • ❌ CVV コード
  • ❌ 有効期限
  • ❌ 請求先住所(ユーザーが別途入力した場合を除く)
保存する情報
  • ✅ サブスクリプションのステータス(free/basic/pro)
  • ✅ サブスクリプションの開始日
  • ✅ 顧客 ID(Stripe の内部 ID。カード情報ではありません)
このアーキテクチャのメリット
  • PCI-DSS の対象にはなりません(対象となるのは Stripe です)
  • カード情報を漏えいさせることはできません(当社がカード情報を保持することはありません)
  • Stripe が不正検出を処理します
  • Stripe が3D セキュアを処理します
  • Clerk がWebhook セキュリティを処理します

実装ファイル

  • components/custom-clerk-pricing.tsx - 料金表コンポーネント
  • app/dashboard/payment-gated/page.tsx - サブスクリプションでアクセスを制御する例
  • convex/http.ts - Webhook 受信エンドポイント(Svix が署名を検証)

Clerk Billing の設定

1. Clerk Dashboard で設定する
  1. Clerk Dashboard → Billing に移動します
  2. Stripe アカウントを接続します
  3. サブスクリプションプランを作成します(Free、Basic、Pro)
  4. Clerk Billing の公開可能キーをコピーします
2. 環境変数
# .env.local

# Clerk Billing
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

# Stripe (automatically configured by Clerk Billing)
# No manual Stripe keys needed!

# Webhook signing secret (from Clerk)
CLERK_WEBHOOK_SECRET=whsec_...
3. 料金表コンポーネントを追加する
// components/custom-clerk-pricing.tsx
'use client';

import { PricingTable } from '@clerk/clerk-react';

export function CustomClerkPricing() {
  return (
    <div className="pricing-container">
      <h1>Choose Your Plan</h1>

      <PricingTable
        appearance={{
          elements: {
            card: 'border rounded-lg p-6',
            cardActive: 'border-blue-500',
            button: 'bg-blue-600 hover:bg-blue-700 text-white',
          }
        }}
      />
    </div>
  );
}

サブスクリプションのステータスを確認する

サーバーサイド(API ルート)
// app/api/premium-feature/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { handleUnauthorizedError, handleForbiddenError } from '@/lib/errorHandler';

export async function GET(request: NextRequest) {
  const { userId, sessionClaims } = await auth();

  if (!userId) {
    return handleUnauthorizedError();
  }

  // Check subscription status from Clerk
  const plan = sessionClaims?.metadata?.plan as string;

  if (plan === 'free_user') {
    return handleForbiddenError('Premium subscription required');
  }

  // User has paid subscription
  return NextResponse.json({
    message: 'Welcome to premium feature!',
    plan: plan
  });
}
クライアントサイド(コンポーネント)
'use client';

import { Protect } from '@clerk/nextjs';
import Link from 'next/link';

export function PremiumFeature() {
  return (
    <Protect
      condition={(has) => !has({ plan: "free_user" })}
      fallback={<UpgradePrompt />}
    >
      <div>
        {/* Premium feature content */}
        <h2>Premium Feature</h2>
        <p>This content is only visible to paid subscribers</p>
      </div>
    </Protect>
  );
}

function UpgradePrompt() {
  return (
    <div className="upgrade-prompt">
      <h3>Upgrade to Premium</h3>
      <p>This feature is available on our paid plans</p>
      <Link href="/pricing">
        <button>View Pricing</button>
      </Link>
    </div>
  );
}

決済でアクセスを制御するページの完全な例

// app/dashboard/payment-gated/page.tsx
'use client';

import { Protect } from '@clerk/nextjs';
import { CustomClerkPricing } from '@/components/custom-clerk-pricing';

export default function PaymentGatedPage() {
  return (
    <div>
      <Protect
        condition={(has) => !has({ plan: "free_user" })}
        fallback={
          <div className="upgrade-required">
            <h1>Premium Access Required</h1>
            <p>Subscribe to access this page</p>
            <CustomClerkPricing />
          </div>
        }
      >
        <div className="premium-content">
          <h1>Premium Dashboard</h1>
          <p>Welcome to the premium features!</p>
          {/* Premium features here */}
        </div>
      </Protect>
    </div>
  );
}

Webhook の処理

Clerk Webhook(ユーザーとサブスクリプションのイベント)
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;

  if (!WEBHOOK_SECRET) {
    throw new Error('Missing CLERK_WEBHOOK_SECRET');
  }

  // Get webhook headers
  const headerPayload = headers();
  const svix_id = headerPayload.get("svix-id");
  const svix_timestamp = headerPayload.get("svix-timestamp");
  const svix_signature = headerPayload.get("svix-signature");

  if (!svix_id || !svix_timestamp || !svix_signature) {
    return new Response('Missing svix headers', { status: 400 });
  }

  const payload = await request.json();
  const body = JSON.stringify(payload);

  // Verify webhook signature
  const wh = new Webhook(WEBHOOK_SECRET);
  let evt: any;

  try {
    evt = wh.verify(body, {
      "svix-id": svix_id,
      "svix-timestamp": svix_timestamp,
      "svix-signature": svix_signature,
    });
  } catch (err) {
    console.error('Webhook verification failed:', err);
    return new Response('Invalid signature', { status: 400 });
  }

  const { id, type, data } = evt;

  // Handle subscription events
  switch (type) {
    case 'subscription.created':
      await handleSubscriptionCreated(data);
      break;

    case 'subscription.updated':
      await handleSubscriptionUpdated(data);
      break;

    case 'subscription.deleted':
      await handleSubscriptionDeleted(data);
      break;

    case 'user.created':
      await handleUserCreated(data);
      break;

    case 'user.updated':
      await handleUserUpdated(data);
      break;
  }

  return new Response('', { status: 200 });
}

async function handleSubscriptionCreated(data: any) {
  const { user_id, plan, stripe_customer_id } = data;

  // Store subscription in database
  await db.subscriptions.create({
    userId: user_id,
    plan: plan,
    stripeCustomerId: stripe_customer_id,
    status: 'active',
    createdAt: Date.now()
  });

  // Update user metadata
  await db.users.update(
    { clerkId: user_id },
    { plan: plan, updatedAt: Date.now() }
  );
}

async function handleSubscriptionUpdated(data: any) {
  const { user_id, plan, status } = data;

  await db.subscriptions.update(
    { userId: user_id },
    {
      plan: plan,
      status: status,
      updatedAt: Date.now()
    }
  );

  // Update user plan
  await db.users.update(
    { clerkId: user_id },
    { plan: plan }
  );
}

async function handleSubscriptionDeleted(data: any) {
  const { user_id } = data;

  await db.subscriptions.update(
    { userId: user_id },
    {
      status: 'cancelled',
      cancelledAt: Date.now()
    }
  );

  // Downgrade to free
  await db.users.update(
    { clerkId: user_id },
    { plan: 'free_user' }
  );
}
Convex Webhook(代替方法)

Convex を使用する場合は、Clerk の Webhook を直接受信できます。

// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { Webhook } from "svix";
import { internal } from "./_generated/api";

const http = httpRouter();

http.route({
  path: "/clerk-webhook",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const payload = await request.text();
    const headers = request.headers;

    const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);

    let evt: any;
    try {
      evt = wh.verify(payload, {
        "svix-id": headers.get("svix-id")!,
        "svix-timestamp": headers.get("svix-timestamp")!,
        "svix-signature": headers.get("svix-signature")!,
      });
    } catch (err) {
      return new Response("Invalid signature", { status: 400 });
    }

    const { type, data } = evt;

    switch (type) {
      case "subscription.created":
        await ctx.runMutation(internal.subscriptions.create, {
          userId: data.user_id,
          plan: data.plan,
          stripeCustomerId: data.stripe_customer_id,
        });
        break;

      case "subscription.updated":
        await ctx.runMutation(internal.subscriptions.update, {
          userId: data.user_id,
          plan: data.plan,
          status: data.status,
        });
        break;

      case "subscription.deleted":
        await ctx.runMutation(internal.subscriptions.cancel, {
          userId: data.user_id,
        });
        break;
    }

    return new Response("", { status: 200 });
  }),
});

export default http;

決済のテスト

テストモード(Stripe テストカード)

開発時は必ず Stripe のテストカードを使用してください。

Success: 4242 4242 4242 4242
Decline: 4000 0000 0000 0002
3D Secure: 4000 0025 0000 3155
Insufficient funds: 4000 0000 0000 9995

CVV: Any 3 digits
Expiry: Any future date
ZIP: Any 5 digits
サブスクリプションのフローをテストする
  1. /pricing に移動します
  2. 有料プランの「Subscribe」をクリックします
  3. Clerk が Stripe Checkout を開きます
  4. テストカードを入力します:4242 4242 4242 4242
  5. チェックアウトを完了します
  6. サブスクリプションのステータスが更新されたことを確認します
  7. プレミアム機能にアクセスできることを確認します
Webhook をテストする
# Use Stripe CLI to forward webhooks to local
stripe listen --forward-to localhost:3000/api/webhooks/clerk

# Trigger test subscription event
stripe trigger subscription.created

決済失敗への対応

決済失敗のフロー
  1. Stripe がカードへの請求を試みます
  2. 決済に失敗します(カードの期限切れ、残高不足など)
  3. Stripe が Clerk に通知します
  4. Clerk が Webhook を送信します:subscription.payment_failed
  5. メールでユーザーに通知します
  6. Stripe が再試行します(スマートな再試行ロジック)
  7. 再試行後も失敗する場合 → サブスクリプションがキャンセルされます
決済失敗への対応を実装する
// In webhook handler
case 'subscription.payment_failed':
  await handlePaymentFailed(data);
  break;

async function handlePaymentFailed(data: any) {
  const { user_id, attempt_count } = data;

  // Update subscription status
  await db.subscriptions.update(
    { userId: user_id },
    {
      status: 'past_due',
      lastPaymentFailed: Date.now(),
      failedAttempts: attempt_count
    }
  );

  // Send email to user
  await sendEmail({
    to: getUserEmail(user_id),
    subject: 'Payment Failed',
    template: 'payment-failed',
    data: {
      attemptCount: attempt_count,
      retryDate: calculateRetryDate(attempt_count)
    }
  });
}

セキュリティのベストプラクティス

1. Webhook を必ず検証する

❌ 検証せずに Webhook データを信頼しないでください。

// Bad - no signature verification
export async function POST(request: NextRequest) {
  const data = await request.json();
  // Process data directly - could be forged!
}

✅ Webhook の署名を必ず検証してください。

// Good - signature verified by Svix
const wh = new Webhook(WEBHOOK_SECRET);
const evt = wh.verify(body, headers); // Throws if invalid
// Now safe to process
2. 支払い情報を保存しない

❌ カード情報を保存しないでください:

// Bad - PCI-DSS violation
await db.payments.create({
  userId,
  cardNumber: '4242424242424242', // ❌ NEVER DO THIS
  cvv: '123',                      // ❌ NEVER DO THIS
  expiry: '12/25'                  // ❌ NEVER DO THIS
});

✅ Stripe ID のみを保存してください:

// Good - no card data
await db.subscriptions.create({
  userId,
  stripeCustomerId: 'cus_123',        // ✅ Stripe internal ID
  stripeSubscriptionId: 'sub_456',   // ✅ Stripe internal ID
  plan: 'pro',
  status: 'active'
});
3. サーバーでサブスクリプションのステータスを確認する

❌ クライアント側のチェックに依存しないでください:

// Bad - can be bypassed
'use client';
const { user } = useUser();
if (user?.publicMetadata?.plan === 'pro') {
  // Show premium feature - attacker can fake this
}

✅ サーバーで検証してください:

// Good - secure
export async function GET(request: NextRequest) {
  const { sessionClaims } = await auth();
  const plan = sessionClaims?.metadata?.plan;

  if (plan !== 'pro') {
    return handleForbiddenError();
  }

  // Premium feature access
}
4. 冪等性を実装する

重複した Webhook を処理します(Stripe が再試行する場合があります):

// Track processed webhook IDs
const processedWebhooks = new Set<string>();

export async function POST(request: NextRequest) {
  const evt = await verifyWebhook(request);
  const { id } = evt;

  // Check if already processed
  if (processedWebhooks.has(id)) {
    return new Response('Already processed', { status: 200 });
  }

  // Process webhook
  await handleWebhook(evt);

  // Mark as processed
  processedWebhooks.add(id);

  return new Response('', { status: 200 });
}

Clerk Billing が処理すること

✅ Stripe API との連携 - Stripe のコードを手動で記述する必要はありません ✅ 顧客の作成/管理 - 自動で処理されます ✅ サブスクリプションのライフサイクル - 作成、更新、キャンセル ✅ Webhook 署名の検証 - Svix 経由で組み込み済み ✅ ユーザー/サブスクリプションの同期 - メタデータを自動で更新 ✅ 冪等性 - 重複した Webhook を処理します ✅ 再試行ロジック - Webhook の失敗時にスマートに再試行します

このアーキテクチャで防げること

✅ PCI-DSS 準拠の負担 - PCI-DSS の対象にはなりません ✅ カード情報の漏えい - カード情報を保持することはありません ✅ 決済詐欺 - Stripe の不正検知機能で防ぎます ✅ Webhook の偽造 - Svix の署名検証で防ぎます ✅ 中間者攻撃 - Stripe Checkout は HTTPS のみを使用します ✅ セッションハイジャック - Clerk の安全なセッション管理で防ぎます

避けるべきよくある間違い

❌ カードを自分で処理しようとしないでください ❌ 決済カード情報を一切保存しないでください ❌ 検証せずに Webhook データを信頼しないでください ❌ アクセス制御において、クライアント側のサブスクリプション確認に依存しないでください ❌ 支払い失敗への対応を忘れないでください ❌ Stripe のシークレットキーをクライアントコードに公開しないでください

✅ Clerk Billing と Stripe Checkout を使用してください ✅ Webhook の署名(Svix)を検証してください ✅ サーバーでサブスクリプションのステータスを確認してください ✅ Webhook イベント(作成、更新、キャンセル)を処理してください ✅ 開発環境では Stripe のテストカードを使用してください ✅ Webhook に対して冪等性を実装してください

参考資料

次のステップ

  • サブスクリプションベースのアクセス制御には、Protect コンポーネントを含む auth-security スキルを使用してください
  • Webhook エンドポイントのセキュリティには、rate-limiting スキルと組み合わせてください
  • 決済処理のエラー対応には、error-handling スキルを使用してください
  • テストには、security-testing スキルを使用してください

コメント