payment-security-clerk-billing-stripe

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Payment 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:
  1. Frontend shows Clerk's
    PricingTable
    component
  2. User clicks subscribe → Clerk opens Stripe Checkout
  3. User enters card → Stripe's servers (not ours)
  4. Stripe processes payment → Stripe's servers (not ours)
  5. Stripe notifies Clerk → Webhook (verified by Clerk)
  6. Clerk updates subscription status
  7. Clerk notifies Convex → Webhook to our database
  8. Our app reads subscription status → Grants access
用户订阅流程:
  1. 前端展示Clerk的
    PricingTable
    组件
  2. 用户点击订阅 → Clerk打开Stripe Checkout
  3. 用户输入卡号 → 上传至Stripe服务器(而非我们的服务器)
  4. Stripe处理支付 → 在Stripe服务器完成(而非我们的服务器)
  5. Stripe通知Clerk → Webhook(由Clerk验证)
  6. Clerk更新订阅状态
  7. Clerk通知Convex → 发送Webhook到我们的数据库
  8. 我们的应用读取订阅状态 → 授予用户对应权限

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

实现文件

  • components/custom-clerk-pricing.tsx
    - Pricing table component
  • app/dashboard/payment-gated/page.tsx
    - Example of subscription gating
  • convex/http.ts
    - Webhook receiver (signature verified by Svix)
  • components/custom-clerk-pricing.tsx
    - 定价表组件
  • app/dashboard/payment-gated/page.tsx
    - 订阅门槛示例页面
  • convex/http.ts
    - Webhook接收器(由Svix验证签名)

Setting Up Clerk Billing

设置Clerk Billing

1. Configure in Clerk Dashboard

1. 在Clerk Dashboard中配置

  1. Go to Clerk Dashboard → Billing
  2. Connect Stripe account
  3. Create subscription plans (Free, Basic, Pro)
  4. Copy Clerk Billing publishable key
  1. 进入Clerk Dashboard → 计费(Billing)页面
  2. 关联Stripe账户
  3. 创建订阅方案(免费、基础版、专业版)
  4. 复制Clerk Billing发布密钥

2. Environment Variables

2. 环境变量

bash
undefined
bash
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_...
undefined
CLERK_WEBHOOK_SECRET=whsec_...
undefined

3. 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

测试订阅流程

  1. Go to
    /pricing
  2. Click "Subscribe" on a paid plan
  3. Clerk opens Stripe Checkout
  4. Enter test card:
    4242 4242 4242 4242
  5. Complete checkout
  6. Verify subscription status updated
  7. Check premium features are accessible
  1. 访问
    /pricing
    页面
  2. 点击付费方案的「订阅」按钮
  3. Clerk打开Stripe Checkout页面
  4. 输入测试卡:
    4242 4242 4242 4242
  5. 完成结账流程
  6. 验证订阅状态已更新
  7. 确认高级功能可正常访问

Testing Webhook

测试Webhook

bash
undefined
bash
undefined

Use 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
undefined
stripe trigger subscription.created
undefined

Handling Failed Payments

处理支付失败

Failed Payment Flow

支付失败流程

  1. Stripe attempts to charge card
  2. Payment fails (expired card, insufficient funds, etc.)
  3. Stripe notifies Clerk
  4. Clerk sends webhook:
    subscription.payment_failed
  5. We notify user via email
  6. Stripe retries (smart retry logic)
  7. If still fails after retries → subscription cancelled
  1. Stripe尝试扣款
  2. 支付失败(卡片过期、余额不足等原因)
  3. Stripe通知Clerk
  4. Clerk发送webhook:
    subscription.payment_failed
  5. 我们向用户发送邮件通知
  6. Stripe重试扣款(智能重试逻辑)
  7. 若重试后仍失败 → 订阅取消

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 yourselfDON'T store any payment card informationDON'T trust webhook data without verificationDON'T rely on client-side subscription checks for access controlDON'T forget to handle failed paymentsDON'T expose Stripe secret keys in client code
DO use Clerk Billing + Stripe CheckoutDO verify webhook signatures (Svix)DO check subscription status on serverDO handle webhook events (created, updated, cancelled)DO test with Stripe test cards in developmentDO implement idempotency for webhooks
❌ 不要尝试自行处理卡片支付 ❌ 不要存储任何支付卡片信息 ❌ 不要未经验证就信任webhook数据 ❌ 不要依赖客户端订阅校验做访问控制 ❌ 不要忘记处理支付失败场景 ❌ 不要在客户端代码中暴露Stripe密钥
✅ 请使用Clerk Billing + Stripe Checkout ✅ 请验证webhook签名(Svix) ✅ 请在服务端检查订阅状态 ✅ 请处理所有webhook事件(创建、更新、取消) ✅ 请在开发环境使用Stripe测试卡测试 ✅ 请为webhook实现幂等性

References

参考资料

Next Steps

后续步骤

  • For subscription-based access control: Use
    auth-security
    skill with Protect component
  • For webhook endpoint security: Combine with
    rate-limiting
    skill
  • For error handling in payment processing: Use
    error-handling
    skill
  • For testing: Use
    security-testing
    skill
  • 实现订阅类访问控制:结合
    auth-security
    技能和Protect组件使用
  • 提升webhook端点安全性:结合
    rate-limiting
    技能使用
  • 支付流程错误处理:使用
    error-handling
    技能
  • 测试相关:使用
    security-testing
    技能