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 合规要求

如果您存储、处理或传输信用卡数据,就必须遵守 支付卡行业数据安全标准(PCI-DSS)。要求包括:

  • 每年进行安全审计(20,000–50,000 美元)
  • 每季度进行漏洞扫描
  • 安全的网络架构
  • 加密持卡人数据
  • 访问控制措施
  • 定期进行安全测试

小型公司: 84% 未能通过首次 PCI 审计

持续合规成本: 每年 50,000–200,000 美元

真实的支付处理失败案例

Target 数据泄露事件(2013 年): 由于存储了支付数据且安全措施不足,4,100 万个银行卡账户遭到泄露。 和解金额:1,850 万美元

Home Depot 数据泄露事件(2014 年): 5,600 万张银行卡被盗。他们在本地存储了银行卡数据。 和解金额:1,750 万美元

安全做法:绝不接触银行卡数据

通过使用 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 Secure
  • 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. 在付费方案上点击“订阅”
  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 实现幂等性

参考资料

后续步骤

  • 对于基于订阅的访问控制:使用 auth-security 技能和 Protect 组件
  • 对于 Webhook 端点安全:与 rate-limiting 技能结合使用
  • 对于支付处理中的错误处理:使用 error-handling 技能
  • 对于测试:使用 security-testing 技能

评论

支持 Markdown