payment-security-clerk-billing-stripe
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePayment Security - Clerk Billing + Stripe
支付安全 - Clerk Billing + Stripe
Why We Don't Handle Payments Directly
为什么我们不直接处理支付
PCI-DSS Compliance Requirements
PCI-DSS 合规要求
If you store, process, or transmit credit card data, you must comply with Payment Card Industry Data Security Standard (PCI-DSS). Requirements include:
- Annual security audits ($20,000-$50,000)
- Quarterly vulnerability scans
- Secure network architecture
- Encryption of cardholder data
- Access control measures
- Regular security testing
Small companies: 84% fail initial PCI audit
Ongoing compliance costs: $50,000-$200,000 annually
如果你存储、处理或传输信用卡数据,就必须遵守支付卡行业数据安全标准(PCI-DSS),相关要求包括:
- 年度安全审计(20,000-50,000美元)
- 季度漏洞扫描
- 安全网络架构
- 持卡人数据加密
- 访问控制措施
- 定期安全测试
小型企业: 84%首次PCI审计不通过
持续合规成本: 每年50,000-200,000美元
Real-World Payment Handling Failures
真实支付处理安全事故
Target Breach (2013):
41 million card accounts compromised because they stored payment data and had insufficient security.
Settlement: $18.5 million
Home Depot Breach (2014):
56 million cards stolen. They were storing card data locally.
Settlement: $17.5 million
Target数据泄露(2013年):
因存储支付数据且安全措施不足,4100万张卡片账户信息被泄露,和解金额:1850万美元
家得宝数据泄露(2014年):
5600万张卡片信息被盗,原因是他们在本地存储了卡片数据,和解金额:1750万美元
The Secure Approach: Never Touch Card Data
安全方案:永远不接触卡片数据
By using Clerk Billing + Stripe, we never see, store, or transmit credit card data. We're not subject to PCI-DSS. Stripe is.
通过使用 Clerk Billing + Stripe,我们永远不会查看、存储或传输信用卡数据,我们不需要遵守PCI-DSS要求,Stripe会负责相关合规工作。
Our Payment Architecture
我们的支付架构
What Happens (What DOESN'T Happen)
实际流程(及不会发生的流程)
User subscribes:
- Frontend shows Clerk's component
PricingTable - User clicks subscribe → Clerk opens Stripe Checkout
- User enters card → Stripe's servers (not ours)
- Stripe processes payment → Stripe's servers (not ours)
- Stripe notifies Clerk → Webhook (verified by Clerk)
- Clerk updates subscription status
- Clerk notifies Convex → Webhook to our database
- Our app reads subscription status → Grants access
用户订阅流程:
- 前端展示Clerk的组件
PricingTable - 用户点击订阅 → Clerk打开Stripe Checkout
- 用户输入卡号 → 上传至Stripe服务器(而非我们的服务器)
- Stripe处理支付 → 在Stripe服务器完成(而非我们的服务器)
- Stripe通知Clerk → Webhook(由Clerk验证)
- Clerk更新订阅状态
- Clerk通知Convex → 发送Webhook到我们的数据库
- 我们的应用读取订阅状态 → 授予用户对应权限
What Never Touches Our Servers
永远不会接触我们服务器的信息
- ❌ Credit card numbers
- ❌ CVV codes
- ❌ Expiration dates
- ❌ Billing addresses (unless user separately provides)
- ❌ 信用卡号
- ❌ CVV码
- ❌ 有效期
- ❌ 账单地址(除非用户单独提供)
What We Store
我们存储的信息
- ✅ Subscription status (free/basic/pro)
- ✅ Subscription start date
- ✅ Customer ID (Stripe's internal ID, not card info)
- ✅ 订阅状态(免费/基础版/专业版)
- ✅ 订阅开始日期
- ✅ 客户ID(Stripe内部ID,非卡片信息)
This Architecture Means
该架构的优势
- We're NOT subject to PCI-DSS (Stripe is)
- We can't leak card data (we never have it)
- Stripe handles fraud detection
- Stripe handles 3D Secure
- Clerk handles webhook security
- 我们无需遵守PCI-DSS要求(Stripe负责)
- 我们不可能泄露卡片数据(我们根本不会持有这类数据)
- Stripe负责欺诈检测
- Stripe负责3D Secure验证
- Clerk负责Webhook安全
Implementation Files
实现文件
- - Pricing table component
components/custom-clerk-pricing.tsx - - Example of subscription gating
app/dashboard/payment-gated/page.tsx - - Webhook receiver (signature verified by Svix)
convex/http.ts
- - 定价表组件
components/custom-clerk-pricing.tsx - - 订阅门槛示例页面
app/dashboard/payment-gated/page.tsx - - Webhook接收器(由Svix验证签名)
convex/http.ts
Setting Up Clerk Billing
设置Clerk Billing
1. Configure in Clerk Dashboard
1. 在Clerk Dashboard中配置
- Go to Clerk Dashboard → Billing
- Connect Stripe account
- Create subscription plans (Free, Basic, Pro)
- Copy Clerk Billing publishable key
- 进入Clerk Dashboard → 计费(Billing)页面
- 关联Stripe账户
- 创建订阅方案(免费、基础版、专业版)
- 复制Clerk Billing发布密钥
2. Environment Variables
2. 环境变量
bash
undefinedbash
undefined.env.local
.env.local
Clerk Billing
Clerk Billing
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
Stripe (automatically configured by Clerk Billing)
Stripe(由Clerk Billing自动配置,无需手动设置Stripe密钥!)
No manual Stripe keys needed!
Webhook签名密钥(从Clerk获取)
Webhook signing secret (from Clerk)
—
CLERK_WEBHOOK_SECRET=whsec_...
undefinedCLERK_WEBHOOK_SECRET=whsec_...
undefined3. Add Pricing Table Component
3. 添加定价表组件
typescript
// 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>
);
}typescript
// components/custom-clerk-pricing.tsx
'use client';
import { PricingTable } from '@clerk/clerk-react';
export function CustomClerkPricing() {
return (
<div className="pricing-container">
<h1>选择你的方案</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>
);
}Checking Subscription Status
检查订阅状态
Server-Side (API Routes)
服务端(API路由)
typescript
// 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
});
}typescript
// 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();
}
// 从Clerk获取订阅状态
const plan = sessionClaims?.metadata?.plan as string;
if (plan === 'free_user') {
return handleForbiddenError('需要高级订阅权限');
}
// 用户拥有付费订阅
return NextResponse.json({
message: '欢迎使用高级功能!',
plan: plan
});
}Client-Side (Components)
客户端(组件)
typescript
'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>
);
}typescript
'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>
{/* 高级功能内容 */}
<h2>高级功能</h2>
<p>该内容仅付费订阅用户可见</p>
</div>
</Protect>
);
}
function UpgradePrompt() {
return (
<div className="upgrade-prompt">
<h3>升级到高级版</h3>
<p>该功能仅在付费方案中提供</p>
<Link href="/pricing">
<button>查看定价</button>
</Link>
</div>
);
}Complete Payment-Gated Page Example
完整支付门槛页面示例
typescript
// 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>
);
}typescript
// 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>需要高级访问权限</h1>
<p>订阅后即可访问该页面</p>
<CustomClerkPricing />
</div>
}
>
<div className="premium-content">
<h1>高级仪表盘</h1>
<p>欢迎使用高级功能!</p>
{/* 高级功能代码 */}
</div>
</Protect>
</div>
);
}Webhook Handling
Webhook处理
Clerk Webhook (User & Subscription Events)
Clerk Webhook(用户与订阅事件)
typescript
// 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' }
);
}typescript
// 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('缺少CLERK_WEBHOOK_SECRET配置');
}
// 获取webhook请求头
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('缺少svix请求头', { status: 400 });
}
const payload = await request.json();
const body = JSON.stringify(payload);
// 验证webhook签名
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验证失败:', err);
return new Response('无效签名', { status: 400 });
}
const { id, type, data } = evt;
// 处理订阅事件
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;
// 在数据库中存储订阅信息
await db.subscriptions.create({
userId: user_id,
plan: plan,
stripeCustomerId: stripe_customer_id,
status: 'active',
createdAt: Date.now()
});
// 更新用户元数据
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()
}
);
// 更新用户方案
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()
}
);
// 降级为免费用户
await db.users.update(
{ clerkId: user_id },
{ plan: 'free_user' }
);
}Convex Webhook (Alternative)
Convex Webhook(替代方案)
If using Convex, you can receive Clerk webhooks directly:
typescript
// 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;如果你使用Convex,可以直接接收Clerk webhook:
typescript
// 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("无效签名", { 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;Testing Payments
测试支付
Test Mode (Stripe Test Cards)
测试模式(Stripe测试卡)
Always use Stripe test cards in development:
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开发环境请始终使用Stripe测试卡:
支付成功:4242 4242 4242 4242
支付拒绝:4000 0000 0000 0002
3D Secure验证:4000 0025 0000 3155
余额不足:4000 0000 0000 9995
CVV:任意3位数字
有效期:任意未来日期
ZIP码:任意5位数字Testing Subscription Flow
测试订阅流程
- Go to
/pricing - Click "Subscribe" on a paid plan
- Clerk opens Stripe Checkout
- Enter test card:
4242 4242 4242 4242 - Complete checkout
- Verify subscription status updated
- Check premium features are accessible
- 访问 页面
/pricing - 点击付费方案的「订阅」按钮
- Clerk打开Stripe Checkout页面
- 输入测试卡:
4242 4242 4242 4242 - 完成结账流程
- 验证订阅状态已更新
- 确认高级功能可正常访问
Testing Webhook
测试Webhook
bash
undefinedbash
undefinedUse Stripe CLI to forward webhooks to local
使用Stripe CLI将webhook转发到本地环境
stripe listen --forward-to localhost:3000/api/webhooks/clerk
stripe listen --forward-to localhost:3000/api/webhooks/clerk
Trigger test subscription event
触发测试订阅创建事件
stripe trigger subscription.created
undefinedstripe trigger subscription.created
undefinedHandling Failed Payments
处理支付失败
Failed Payment Flow
支付失败流程
- Stripe attempts to charge card
- Payment fails (expired card, insufficient funds, etc.)
- Stripe notifies Clerk
- Clerk sends webhook:
subscription.payment_failed - We notify user via email
- Stripe retries (smart retry logic)
- If still fails after retries → subscription cancelled
- Stripe尝试扣款
- 支付失败(卡片过期、余额不足等原因)
- Stripe通知Clerk
- Clerk发送webhook:
subscription.payment_failed - 我们向用户发送邮件通知
- Stripe重试扣款(智能重试逻辑)
- 若重试后仍失败 → 订阅取消
Implementing Payment Failure Handling
实现支付失败处理逻辑
typescript
// 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)
}
});
}typescript
// 在webhook处理器中添加
case 'subscription.payment_failed':
await handlePaymentFailed(data);
break;
async function handlePaymentFailed(data: any) {
const { user_id, attempt_count } = data;
// 更新订阅状态
await db.subscriptions.update(
{ userId: user_id },
{
status: 'past_due',
lastPaymentFailed: Date.now(),
failedAttempts: attempt_count
}
);
// 向用户发送邮件
await sendEmail({
to: getUserEmail(user_id),
subject: '支付失败',
template: 'payment-failed',
data: {
attemptCount: attempt_count,
retryDate: calculateRetryDate(attempt_count)
}
});
}Security Best Practices
安全最佳实践
1. Always Verify Webhooks
1. 始终验证Webhook
❌ DON'T trust webhook data without verification:
typescript
// Bad - no signature verification
export async function POST(request: NextRequest) {
const data = await request.json();
// Process data directly - could be forged!
}✅ DO verify webhook signatures:
typescript
// Good - signature verified by Svix
const wh = new Webhook(WEBHOOK_SECRET);
const evt = wh.verify(body, headers); // Throws if invalid
// Now safe to process❌ 不要: 未经验证就信任webhook数据:
typescript
// 错误写法 - 无签名验证
export async function POST(request: NextRequest) {
const data = await request.json();
// 直接处理数据 - 可能是伪造的请求!
}✅ 请: 验证webhook签名:
typescript
// 正确写法 - 由Svix验证签名
const wh = new Webhook(WEBHOOK_SECRET);
const evt = wh.verify(body, headers); // 签名无效则抛出异常
// 现在可安全处理数据2. Never Store Payment Info
2. 永远不要存储支付信息
❌ DON'T store card data:
typescript
// 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
});✅ DO store Stripe IDs only:
typescript
// 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'
});❌ 不要: 存储卡片数据:
typescript
// 错误写法 - 违反PCI-DSS要求
await db.payments.create({
userId,
cardNumber: '4242424242424242', // ❌ 绝对不要这么做
cvv: '123', // ❌ 绝对不要这么做
expiry: '12/25' // ❌ 绝对不要这么做
});✅ 请: 仅存储Stripe ID:
typescript
// 正确写法 - 无卡片数据
await db.subscriptions.create({
userId,
stripeCustomerId: 'cus_123', // ✅ Stripe内部ID
stripeSubscriptionId: 'sub_456', // ✅ Stripe内部ID
plan: 'pro',
status: 'active'
});3. Check Subscription Status on Server
3. 在服务端检查订阅状态
❌ DON'T rely on client-side checks:
typescript
// Bad - can be bypassed
'use client';
const { user } = useUser();
if (user?.publicMetadata?.plan === 'pro') {
// Show premium feature - attacker can fake this
}✅ DO verify on server:
typescript
// 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
}❌ 不要: 依赖客户端校验:
typescript
// 错误写法 - 可被绕过
'use client';
const { user } = useUser();
if (user?.publicMetadata?.plan === 'pro') {
// 展示高级功能 - 攻击者可以伪造该状态
}✅ 请: 在服务端验证:
typescript
// 正确写法 - 安全
export async function GET(request: NextRequest) {
const { sessionClaims } = await auth();
const plan = sessionClaims?.metadata?.plan;
if (plan !== 'pro') {
return handleForbiddenError();
}
// 授予高级功能访问权限
}4. Implement Idempotency
4. 实现幂等性
Handle duplicate webhooks (Stripe may retry):
typescript
// 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 });
}处理重复webhook(Stripe可能会重试请求):
typescript
// 跟踪已处理的webhook ID
const processedWebhooks = new Set<string>();
export async function POST(request: NextRequest) {
const evt = await verifyWebhook(request);
const { id } = evt;
// 检查是否已处理过该请求
if (processedWebhooks.has(id)) {
return new Response('已处理该请求', { status: 200 });
}
// 处理webhook
await handleWebhook(evt);
// 标记为已处理
processedWebhooks.add(id);
return new Response('', { status: 200 });
}What Clerk Billing Handles
Clerk Billing负责的工作
✅ Stripe API integration - No manual Stripe code needed
✅ Customer creation/management - Automatic
✅ Subscription lifecycle - Create, update, cancel
✅ Webhook signature verification - Built-in via Svix
✅ User/subscription sync - Automatic metadata updates
✅ Idempotency - Handles duplicate webhooks
✅ Retry logic - Smart retry on failed webhooks
✅ Stripe API集成 - 无需手动编写Stripe代码
✅ 客户创建/管理 - 自动完成
✅ 订阅生命周期管理 - 创建、更新、取消
✅ Webhook签名验证 - 通过Svix内置实现
✅ 用户/订阅同步 - 自动更新元数据
✅ 幂等性 - 自动处理重复webhook
✅ 重试逻辑 - 失败webhook智能重试
What This Architecture Prevents
该架构规避的风险
✅ PCI-DSS compliance burden - Not subject to PCI-DSS
✅ Card data breaches - We never have card data
✅ Payment fraud - Stripe's fraud detection
✅ Webhook forgery - Svix signature verification
✅ Man-in-the-middle attacks - Stripe Checkout is HTTPS only
✅ Session hijacking - Clerk's secure session management
✅ PCI-DSS合规负担 - 无需遵守PCI-DSS要求
✅ 卡片数据泄露 - 我们永远不会持有卡片数据
✅ 支付欺诈 - Stripe自带欺诈检测能力
✅ Webhook伪造 - Svix签名验证机制
✅ 中间人攻击 - Stripe Checkout仅支持HTTPS
✅ 会话劫持 - Clerk安全会话管理能力
Common Mistakes to Avoid
需要避免的常见错误
❌ DON'T try to process cards yourself
❌ DON'T store any payment card information
❌ DON'T trust webhook data without verification
❌ DON'T rely on client-side subscription checks for access control
❌ DON'T forget to handle failed payments
❌ DON'T expose Stripe secret keys in client code
✅ DO use Clerk Billing + Stripe Checkout
✅ DO verify webhook signatures (Svix)
✅ DO check subscription status on server
✅ DO handle webhook events (created, updated, cancelled)
✅ DO test with Stripe test cards in development
✅ DO implement idempotency for webhooks
❌ 不要尝试自行处理卡片支付
❌ 不要存储任何支付卡片信息
❌ 不要未经验证就信任webhook数据
❌ 不要依赖客户端订阅校验做访问控制
❌ 不要忘记处理支付失败场景
❌ 不要在客户端代码中暴露Stripe密钥
✅ 请使用Clerk Billing + Stripe Checkout
✅ 请验证webhook签名(Svix)
✅ 请在服务端检查订阅状态
✅ 请处理所有webhook事件(创建、更新、取消)
✅ 请在开发环境使用Stripe测试卡测试
✅ 请为webhook实现幂等性
References
参考资料
- Clerk Billing Documentation: https://clerk.com/docs/billing/overview
- Stripe Checkout: https://stripe.com/docs/payments/checkout
- PCI-DSS Standards: https://www.pcisecuritystandards.org/
- Stripe Testing: https://stripe.com/docs/testing
- Webhook Security: https://docs.svix.com/
- 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/
Next Steps
后续步骤
- For subscription-based access control: Use skill with Protect component
auth-security - For webhook endpoint security: Combine with skill
rate-limiting - For error handling in payment processing: Use skill
error-handling - For testing: Use skill
security-testing
- 实现订阅类访问控制:结合技能和Protect组件使用
auth-security - 提升webhook端点安全性:结合技能使用
rate-limiting - 支付流程错误处理:使用技能
error-handling - 测试相关:使用技能
security-testing