PAYATHON 2026

如何使用 node.js 构建 PCI 合规的支付应用?

支付老李

结论

可以缩小 Node.js 主应用的 PCI DSS 合规范围,但仅把支付代码拆到另一个自建应用中,通常还不够。

更合适的方式是由通过 PCI DSS 验证的支付服务商直接采集银行卡数据。Node.js 后端只接收支付令牌、订单状态和支付结果。常见方案有:

  1. 跳转到支付服务商托管的结账页;
  2. 使用服务商托管的 iframe 或 Hosted Fields;
  3. 在浏览器中直接将银行卡信息令牌化,再把 token 交给 Node.js;
  4. 完全通过服务商的支付链接或账单页面收款。

托管结账页通常最容易控制合规范围。最终适用 SAQ A、SAQ A-EP、SAQ D 还是其他要求,需要由收单机构、支付服务商或合格安全评估机构根据实际数据流确认。不能仅凭”服务器没有保存卡号”自行判断。

为什么拆成另一个自建应用还不够

PCI DSS 不关心应用使用什么编程语言。它关注银行卡数据如何流动,以及哪些系统能够影响支付安全。

如果主应用和”PCI 支付应用”之间存在以下关系,主应用仍可能进入评估范围:

  • 部署在同一个云账号、集群、VPC 或内部网络;
  • 共用数据库、日志系统、消息队列或缓存;
  • 使用相同的管理员账号、CI/CD、密钥管理或监控平台;
  • 主应用能够修改支付页面、支付脚本或支付服务;
  • 主应用能够访问完整卡号、CVV、请求体或相关日志;
  • 两个应用之间没有经过验证的网络分段和权限隔离。

把代码拆成两个仓库或两个 Node.js 进程,并不代表已经完成 PCI 范围隔离。如果银行卡信息仍会经过自己控制的服务器,或者主应用能够影响采集银行卡信息的页面,相关系统依然可能属于合规范围。

推荐架构:服务商托管结账页

这种方案的范围最清晰,维护成本通常也最低。

支付流程如下:

用户浏览器
    │
    │ 请求创建支付
    ▼
Node.js 主应用
    │
    │ 金额、币种、订单号
    ▼
支付服务商 API
    │
    │ 返回托管结账地址
    ▼
用户浏览器 ──直接提交银行卡信息──> 支付服务商
                                      │
                                      │ Webhook
                                      ▼
                                Node.js 主应用

Node.js 应用不创建银行卡输入框,也不接收 PAN、CVV 或磁道数据,只负责:

  • 根据可信的订单数据创建支付;
  • 保存服务商返回的支付对象 ID;
  • 将用户跳转到托管支付页面;
  • 验证 Webhook 签名;
  • 根据异步通知更新订单状态。

下面用常见的 Checkout Session 模式说明。具体 API 名称应以所选支付服务商的当前文档为准。

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.use(express.json());

app.post("/api/payments/checkout", async (req, res) => {
  const { orderId } = req.body;

  // 必须从数据库读取价格,不能相信浏览器提交的金额。
  const order = await loadOrder(orderId);

  if (!order || order.status !== "pending") {
    return res.status(400).json({ error: "Invalid order" });
  }

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [
      {
        price_data: {
          currency: order.currency,
          product_data: {
            name: order.productName
          },
          unit_amount: order.amount
        },
        quantity: 1
      }
    ],
    success_url:
      "https://example.com/payment/success?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "https://example.com/payment/cancel",
    metadata: {
      orderId: order.id
    }
  });

  await savePaymentReference(order.id, session.id);

  res.json({ checkoutUrl: session.url });
});

app.listen(3000);

浏览器只需完成跳转:

const response = await fetch("/api/payments/checkout", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ orderId: "order_123" })
});

const { checkoutUrl } = await response.json();
window.location.assign(checkoutUrl);

不要让前端提交 amount,然后直接用它创建支付,否则用户可以修改请求金额。金额、币种、折扣和商品信息都应由服务器根据订单重新计算。

Webhook 才是支付结果的可信来源

用户进入成功页面,只能说明浏览器完成了跳转,不能证明款项已经支付成功。用户可能关闭页面或伪造请求,支付也可能仍在异步处理中。

订单状态应根据通过签名验证的 Webhook 更新:

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch {
      return res.status(400).send("Invalid webhook signature");
    }

    if (event.type === "checkout.session.completed") {
      const session = event.data.object;
      const orderId = session.metadata?.orderId;

      // 更新操作必须具备幂等性,避免重复 Webhook 导致重复发货。
      await markOrderPaidOnce(orderId, session.id);
    }

    res.sendStatus(200);
  }
);

中间件顺序需要特别注意。签名验证通常依赖原始请求体。如果在 Webhook 路由之前执行 express.json(),原始字节可能会被转换,导致签名验证失败。可以把 Webhook 路由放在 JSON 中间件之前:

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  paymentWebhookHandler
);

app.use(express.json());

不同服务商对签名算法和请求体的要求不同,应按照其官方 SDK 或文档提供的方式进行验证。

方案二:Hosted Fields 或托管 iframe

如果不希望用户离开自己的结账页面,可以使用服务商托管的字段。页面看起来仍属于主应用,但卡号、有效期和 CVV 输入框实际上来自支付服务商的域名。

大致结构如下:

<form id="payment-form">
  <div id="card-number-hosted-field"></div>
  <div id="expiry-hosted-field"></div>
  <div id="cvv-hosted-field"></div>
  <button type="submit">支付</button>
</form>

浏览器通过服务商 SDK 获取 token:

const token = await paymentProvider.tokenizeHostedFields();

await fetch("/api/payments/confirm", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    orderId: "order_123",
    paymentToken: token
  })
});

Node.js 后端只使用 token 创建支付:

app.post("/api/payments/confirm", async (req, res) => {
  const { orderId, paymentToken } = req.body;
  const order = await loadOrder(orderId);

  if (!order || order.status !== "pending") {
    return res.status(400).json({ error: "Invalid order" });
  }

  const payment = await paymentProvider.createPayment({
    amount: order.amount,
    currency: order.currency,
    source: paymentToken,
    idempotencyKey: `pay:${order.id}`
  });

  await savePaymentReference(order.id, payment.id);

  res.json({
    paymentId: payment.id,
    status: payment.status
  });
});

这里的 paymentProvider 只是示意接口,不对应某个固定的 npm 包。

与自行托管银行卡表单相比,Hosted Fields 通常更容易缩小合规范围,但页面脚本安全仍然不能忽视。主页面中的恶意 JavaScript 可能篡改结账流程,因此 PCI DSS 对支付页面脚本、变更检测和安全控制有相应要求。具体问卷类型仍需由服务商或收单机构确认。

方案三:浏览器直接令牌化

有些服务商允许商户页面加载其 JavaScript SDK,将银行卡信息直接发送给服务商,并返回一次性 token。Node.js 服务器只能看到 token。

这种方式远好于让卡号经过 Node.js 后端,但它的评估范围不一定像托管结账页那样小。银行卡输入表单和调用 SDK 的 JavaScript 仍由商户页面控制。如果页面脚本被篡改,卡号可能在令牌化之前遭到窃取。

因此,不能笼统地认为:

使用 JavaScript tokenization = 自动满足 SAQ A

根据具体集成方式、服务商设计和收单机构的判断,它可能涉及 SAQ A-EP 或其他范围。

方案四:独立的自建支付服务

只有业务确实需要直接处理银行卡数据时,才值得考虑自建独立支付服务。例如:

主应用
   │
   │ 内部支付指令
   ▼
支付服务
   │
   │ PAN / CVV
   ▼
Payment Gateway

此时,支付服务及其相关基础设施都必须纳入 PCI DSS 范围。为了避免范围进一步扩大,通常至少需要:

  • 独立的云账号、网络和运行环境;
  • 严格的防火墙与访问控制;
  • 独立的身份、密钥和管理员权限;
  • 经过验证的网络分段;
  • 日志脱敏和敏感数据发现机制;
  • 漏洞扫描、渗透测试、补丁管理和变更控制;
  • 文件完整性、监控、告警和审计;
  • 对 CI/CD、运维终端和第三方访问进行评估;
  • 明确的数据保留与删除策略。

即使隔离措施完善,企业作为商户或服务提供方,仍要承担相应的合规责任。这种做法只能把范围限制在较小的卡数据环境内,不能让 PCI DSS 义务消失。

对大多数团队来说,自建支付服务的成本和风险明显高于使用支付服务商的托管组件。

不要让银行卡数据进入 Node.js

需要确保以下字段不会进入应用服务器:

  • 完整主账号 PAN;
  • CVV、CVC 或 CID;
  • 完整磁道数据;
  • PIN 或 PIN block;
  • 未经保护的银行卡请求体。

尤其不要设计下面这种接口:

// 不推荐:银行卡信息已经进入自己的服务器。
app.post("/api/payments", async (req, res) => {
  const {
    cardNumber,
    expiryMonth,
    expiryYear,
    cvv
  } = req.body;

  // ...
});

即使接口收到数据后立即转发给网关,而且不写入数据库,服务器也已经参与处理持卡人数据。反向代理、APM、错误追踪、请求日志、崩溃转储和调试工具还可能意外留下副本。

可以考虑的替代方案

Balanced 已经不适合新项目。安装一个 Node.js module 也不会让系统自动符合 PCI DSS。合规范围取决于完整的数据流和控制措施。

选择支付服务商时,可以重点比较以下能力:

  • 托管 Checkout;
  • Hosted Fields 或安全 iframe;
  • 前端 tokenization;
  • Webhook 签名验证;
  • 3-D Secure / SCA 流程;
  • 幂等请求;
  • 退款、撤销和争议处理;
  • 支付方式及结算地区;
  • 服务商的 PCI DSS 验证状态和责任矩阵。

常见支付平台通常会提供一种或多种集成模式,例如 Stripe、Adyen、Braintree、Checkout.com 和地区性的收单机构。产品名称、地区可用性和合规说明都可能变化,选型时应查阅服务商当前的官方文档和 PCI DSS 合规资料。

实施时容易忽略的问题

不保存 CVV

CVV 属于敏感认证数据。支付授权完成后,即使已经加密,通常也不得保存。不要把它写入数据库、日志、队列或客户支持记录。

Token 不等于完全无风险

支付 token 通常不能像卡号那样直接使用,但它可能允许对客户发起扣款。因此仍应:

  • 加密保存;
  • 限制访问权限;
  • 不写入普通日志;
  • 绑定商户账户和客户;
  • 防止重复扣款和越权访问。

Webhook 必须防重放

除了验证签名,还应保存事件 ID,并确保订单状态更新具备幂等性:

CREATE UNIQUE INDEX payment_events_event_id_uq
ON payment_events (event_id);

同一条通知可能发送多次,不能因此重复发货或重复记账。

做好前端供应链安全

支付页面应尽量减少第三方脚本,并使用合适的安全策略,例如:

Content-Security-Policy: default-src 'self'; script-src 'self' https://js.payment-provider.example; frame-src https://checkout.payment-provider.example; connect-src 'self' https://api.payment-provider.example

这只是示意配置。真实域名、内联脚本策略和 CSP 指令必须根据服务商要求确定。还需要建立脚本清单、变更审批和篡改检测机制。

防止敏感数据被日志采集

反向代理、Node.js 日志、APM 和错误追踪系统都应统一屏蔽敏感字段:

const loggerOptions = {
  redact: {
    paths: [
      "req.headers.authorization",
      "req.body.cardNumber",
      "req.body.cvv",
      "paymentToken"
    ],
    censor: "[REDACTED]"
  }
};

更稳妥的设计是让这些字段根本不到达应用,而不是在数据进入系统后依靠日志脱敏补救。

建议的落地顺序

  1. 优先选择服务商托管的结账页面。
  2. 必须保留站内体验时,再评估 Hosted Fields。
  3. 绘制浏览器、CDN、主应用、支付服务商、日志和 Webhook 之间的数据流。
  4. 明确证明 PAN 和 CVV 不会经过自己的服务器。
  5. 使用服务商官方 SDK 创建支付并验证 Webhook。
  6. 由服务端计算金额,并确保创建支付和处理通知的操作具备幂等性。
  7. 检查日志、监控、客服系统和分析工具是否会采集支付字段。
  8. 向收单机构或支付服务商确认适用的 SAQ 类型和责任边界。
  9. 上线前完成相应问卷、扫描及其他被要求的验证工作。

判断时可以关注两个问题:银行卡信息是否直接从用户浏览器发送到通过 PCI DSS 验证的服务商,以及主应用能否影响这一采集过程。前者决定卡数据是否进入系统,后者可能决定主应用是否仍在支付安全评估范围内。

备注:内容仅供参考。