如何使用 node.js 构建 PCI 合规的支付应用?
结论
可以缩小 Node.js 主应用的 PCI DSS 合规范围,但仅把支付代码拆到另一个自建应用中,通常还不够。
更合适的方式是由通过 PCI DSS 验证的支付服务商直接采集银行卡数据。Node.js 后端只接收支付令牌、订单状态和支付结果。常见方案有:
- 跳转到支付服务商托管的结账页;
- 使用服务商托管的
iframe或 Hosted Fields; - 在浏览器中直接将银行卡信息令牌化,再把 token 交给 Node.js;
- 完全通过服务商的支付链接或账单页面收款。
托管结账页通常最容易控制合规范围。最终适用 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]"
}
};
更稳妥的设计是让这些字段根本不到达应用,而不是在数据进入系统后依靠日志脱敏补救。
建议的落地顺序
- 优先选择服务商托管的结账页面。
- 必须保留站内体验时,再评估 Hosted Fields。
- 绘制浏览器、CDN、主应用、支付服务商、日志和 Webhook 之间的数据流。
- 明确证明 PAN 和 CVV 不会经过自己的服务器。
- 使用服务商官方 SDK 创建支付并验证 Webhook。
- 由服务端计算金额,并确保创建支付和处理通知的操作具备幂等性。
- 检查日志、监控、客服系统和分析工具是否会采集支付字段。
- 向收单机构或支付服务商确认适用的 SAQ 类型和责任边界。
- 上线前完成相应问卷、扫描及其他被要求的验证工作。
判断时可以关注两个问题:银行卡信息是否直接从用户浏览器发送到通过 PCI DSS 验证的服务商,以及主应用能否影响这一采集过程。前者决定卡数据是否进入系统,后者可能决定主应用是否仍在支付安全评估范围内。
备注:内容仅供参考。
