关于 payment-security-clerk-billing-stripe
payment-security-clerk-billing-stripe 是介绍 Clerk Billing 与 Stripe 订阅支付方案的安全技能,说明如何让卡数据不经过应用服务器,并涵盖订阅权限检查和已验证的 Webhook。
主要功能
- 01Clerk Billing 与 Stripe Checkout 流程
- 02卡数据不经过应用服务器
- 03服务端订阅权限检查
- 04Webhook 签名验证指导
- 05订阅生命周期处理
适用场景
- 01配置订阅结账流程
- 02按订阅状态限制高级功能
- 03处理订阅变更 Webhook
- 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 才受其约束。
我们的支付架构
实际流程(不会发生的事)
用户订阅:
- 前端显示 Clerk 的
PricingTable组件 - 用户点击订阅 → Clerk 打开 Stripe Checkout
- 用户输入银行卡 → Stripe 的服务器(不是我们的)
- Stripe 处理付款 → Stripe 的服务器(不是我们的)
- Stripe 通知 Clerk → Webhook(由 Clerk 验证)
- Clerk 更新订阅状态
- Clerk 通知 Convex → 向我们的数据库发送 Webhook
- 我们的应用读取订阅状态 → 授予访问权限
绝不会到达我们服务器的数据
- ❌ 信用卡号
- ❌ 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 中配置
- 前往 Clerk Dashboard → Billing
- 连接 Stripe 账户
- 创建订阅方案(Free、Basic、Pro)
- 复制 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
测试订阅流程
- 前往
/pricing - 在付费方案上点击“订阅”
- Clerk 打开 Stripe Checkout
- 输入测试卡号:
4242 4242 4242 4242 - 完成结账
- 验证订阅状态已更新
- 检查是否可以访问高级功能
测试 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
处理付款失败
付款失败流程
- Stripe 尝试扣款
- 付款失败(银行卡过期、余额不足等)
- Stripe 通知 Clerk
- Clerk 发送 Webhook:
subscription.payment_failed - 我们通过电子邮件通知用户
- Stripe 重试(智能重试逻辑)
- 如果重试后仍然失败 → 取消订阅
实现付款失败处理
// 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 实现幂等性
参考资料
- Clerk Billing 文档:https://clerk.com/docs/billing/overview
- Stripe Checkout:https://stripe.com/docs/payments/checkout
- PCI-DSS 标准:https://www.pcisecuritystandards.org/
- Stripe 测试:https://stripe.com/docs/testing
- Webhook 安全:https://docs.svix.com/
后续步骤
- 对于基于订阅的访问控制:使用
auth-security技能和 Protect 组件 - 对于 Webhook 端点安全:与
rate-limiting技能结合使用 - 对于支付处理中的错误处理:使用
error-handling技能 - 对于测试:使用
security-testing技能