PAYATHON 2026

如何防止 JWT 被盗后通过 CURL 购买 subscription?

支付小周

明确结论

是的,这是 Bearer JWT 的正常安全模型:只要持有有效 JWT,服务器就会将请求视为对应用户发起。无论使用 curl、浏览器、Postman 还是移动客户端,发送的本质上都是 HTTP 请求。服务器无法,也不应试图通过“禁止 CURL”来保护购买接口。

需要关注的是以下问题:

  • JWT 是如何被窃取的;
  • 被窃取的 JWT 是否权限过大或有效期过长;
  • 仅凭普通登录令牌是否就能完成购买;
  • 支付结果是否经过可信的支付服务端确认;
  • 重放请求是否会造成重复扣款或重复订阅。

如果攻击者同时拿到了有效 JWT 和可用的支付凭据,或者购买接口允许直接使用账户中保存的付款方式,却没有额外验证,那么攻击者确实可能冒充用户操作。购买流程应以“登录令牌可能泄露”为前提进行设计,尽量控制泄露造成的损失。

不能靠阻止 CURL 解决

以下措施都不能形成有效的安全边界:

  • 根据 User-Agent、Origin 或 Referer 拒绝 CURL;
  • 添加一个只有前端代码“知道”的固定请求头;
  • 在 JavaScript 中隐藏 endpoint;
  • 混淆请求参数;
  • 只允许通过页面按钮发起购买。

浏览器发出的请求可以被观察和复现。固定 Header、接口地址以及写在前端代码里的“密钥”同样可以被复制。

Origin、SameSite 等机制可以缓解 CSRF,但无法阻止已经获得 Bearer JWT 的攻击者自行构造请求。

先降低 JWT 被窃取的风险

JWT 存在 sessionStorage 中时,同源页面里运行的 JavaScript 可以读取它。一旦发生 XSS,攻击脚本就可能直接窃取令牌。

常见的改进方式是将会话凭据放进由服务器设置的 Cookie:

Set-Cookie: session=opaque-session-id; HttpOnly; Secure; SameSite=Lax; Path=/

各属性的作用如下:

  • HttpOnly:阻止普通 JavaScript 读取 Cookie;
  • Secure:只通过 HTTPS 发送 Cookie;
  • SameSite=Lax 或 Strict:减少跨站请求携带 Cookie 的机会;
  • Path 和必要的 Domain:尽量限制 Cookie 的发送范围。

使用 Cookie 后仍需处理 CSRF。对于会修改状态的请求,应结合 SameSite、CSRF token 和 Origin 校验。如果业务确实需要跨站请求,则要按照实际部署方式调整 Cookie 和 CSRF 策略。

无论凭据存放在哪里,都要继续防范 XSS:

  • 根据输出上下文进行转义;
  • 不要将不可信内容传给 innerHTML;
  • 使用严格的 Content Security Policy;
  • 谨慎引入第三方脚本;
  • 定期检查依赖和前端注入点;
  • 全站强制使用 HTTPS。

HttpOnly Cookie 不能彻底解决 XSS。攻击脚本即使无法直接读取 Cookie,仍可能在受害者页面中发起请求。因此,高风险操作还需要额外确认。

为购买操作增加独立授权

普通登录状态不应直接等同于“允许立即扣款”。购买或更换支付方式前,可以要求用户重新验证身份,例如:

  • 重新输入密码;
  • 使用 WebAuthn/passkey;
  • 完成一次 MFA;
  • 完成支付服务商要求的 3-D Secure 或其他强客户认证流程。

验证通过后,服务端可以签发一个有效期很短、只能使用一次的操作凭据,不再只依赖普通登录 JWT。这个凭据应绑定:

  • 当前用户;
  • 具体订单;
  • 金额和币种;
  • 操作类型;
  • 很短的过期时间;
  • 唯一随机值 jti;
  • 一次性使用状态。

例如:

{
  "sub": "user_123",
  "aud": "subscription-purchase",
  "scope": "subscription:confirm",
  "order_id": "order_456",
  "amount": 9900,
  "currency": "CNY",
  "jti": "b9f04d7c-...",
  "exp": 1787982300
}

服务端必须查询 jti 的状态,并以原子操作将其标记为已使用。只验证 JWT 签名,无法防止同一个请求被重复提交。

使用服务端控制的支付状态机

不能让客户端直接宣布“支付成功”。更可靠的处理流程是:

  1. 客户端请求创建订单。
  2. 服务端从数据库读取套餐价格,并创建状态为 pending 的订单。
  3. 服务端调用支付服务商,创建 Payment Intent、Checkout Session 或等价对象。
  4. 客户端完成支付服务商要求的交互。
  5. 支付服务商通过签名 webhook 将支付结果通知服务端。
  6. 服务端验证 webhook 签名、金额、币种、商户账户和支付对象 ID。
  7. 服务端通过幂等事务将订单状态改为 paid,再开通 subscription。

客户端收到的“支付完成”回调只能用来刷新界面,不能作为开通订阅的最终依据。最终支付状态应来自支付服务商的服务端 API,或经过签名验证的 webhook。

价格也不能由客户端决定:

{
  "plan": "pro",
  "amount": 1
}

服务端只能接受 plan 这类选择标识,并自行查询实际价格。客户端传来的 amount、折扣、用户 ID 和支付状态都不能直接作为可信事实。

接口实现示例

下面是一个简化的 Express 风格示例。具体的支付 SDK 和数据库事务写法需要按照实际技术栈调整:

app.post(
  "/subscribe/new",
  requireAuthenticatedSession,
  requireRecentStrongAuthentication,
  requireCsrfProtection,
  async (req, res) => {
    const userId = req.auth.userId;
    const { planId, idempotencyKey } = req.body;

    if (!idempotencyKey) {
      return res.status(400).json({ error: "missing_idempotency_key" });
    }

    const plan = await db.plans.findActiveById(planId);

    if (!plan) {
      return res.status(400).json({ error: "invalid_plan" });
    }

    // 必须由数据库唯一约束保证:
    // UNIQUE(user_id, idempotency_key)
    const existing = await db.orders.findByIdempotencyKey(
      userId,
      idempotencyKey
    );

    if (existing) {
      return res.status(200).json(publicOrder(existing));
    }

    const order = await db.transaction(async (tx) => {
      const created = await tx.orders.insert({
        userId,
        planId: plan.id,
        amount: plan.price,
        currency: plan.currency,
        status: "pending",
        idempotencyKey
      });

      const payment = await paymentProvider.createPayment({
        amount: created.amount,
        currency: created.currency,
        metadata: {
          orderId: created.id,
          userId
        },
        idempotencyKey: created.id
      });

      return tx.orders.attachPayment(created.id, payment.id);
    });

    return res.status(201).json({
      orderId: order.id,
      paymentClientSecret: order.paymentClientSecret
    });
  }
);

Webhook 处理同样必须验证签名并保证幂等:

app.post(
  "/payments/webhook",
  rawBodyParser,
  async (req, res) => {
    let event;

    try {
      event = paymentProvider.verifyWebhook(
        req.body,
        req.headers["payment-signature"]
      );
    } catch {
      return res.status(400).send("invalid signature");
    }

    if (event.type !== "payment.succeeded") {
      return res.sendStatus(200);
    }

    const payment = event.data;

    await db.transaction(async (tx) => {
      const order = await tx.orders.lockByPaymentId(payment.id);

      if (!order || order.status === "paid") {
        return;
      }

      if (
        payment.amount !== order.amount ||
        payment.currency !== order.currency
      ) {
        throw new Error("payment details do not match order");
      }

      await tx.orders.markPaid(order.id);
      await tx.subscriptions.activate(order.userId, order.planId);
    });

    return res.sendStatus(200);
  }
);

数据库唯一约束、行锁或功能相同的并发控制不可缺少。如果只在应用代码中先查询再写入,并发请求仍可能产生重复订单。

限制令牌泄露后的损失

认证体系还应包含以下控制:

  • 缩短 Access Token 的有效期;
  • 轮换 Refresh Token,并检测旧令牌是否被重复使用;
  • 在用户注销、修改密码或发现异常时撤销会话;
  • 明确校验 JWT 的 iss、aud、exp、签名算法和权限;
  • 不允许算法降级,也不信任未经验证的 claims;
  • 为普通登录令牌和购买确认令牌设置不同的 aud 或 scope;
  • 对支付接口设置合理的频率限制;
  • 在出现新设备、异常 IP 或短时间内重复购买等情况时触发二次验证;
  • 向用户发送购买通知,并提供会话和设备管理功能;
  • 在日志中记录订单 ID、用户 ID、会话 ID 和处理结果,但不要记录完整 JWT、银行卡数据或支付密钥。

仅将 IP 地址写入 JWT 通常并不可靠,因为移动网络、代理和 NAT 都可能导致地址变化。设备绑定、DPoP 或 mTLS 可以增加令牌重放的难度,但实现和部署成本较高,也无法替代 XSS 防护、二次验证和正确的支付状态机。

支付数据的处理边界

尽量不要让自己的服务直接接收或保存原始银行卡信息。优先使用合规支付服务商提供的托管结账页、客户端组件或 tokenization,将卡号转换为只能由指定商户使用的支付方式标识。

如果允许使用账户中已经保存的付款方式,风险会更高,因为攻击者不再需要另行获得有效的支付信息。这种情况下尤其需要近期强认证,并根据金额、设备和行为风险判断是否再次要求支付验证。

合法客户端通过 HTTP 调用购买接口本身没有问题。安全性来自服务端授权、短时强认证、可信的支付确认、幂等控制和完整的会话防护,而不是区分浏览器与 CURL。

备注:内容仅供参考。