首次实现 payment gateway 应注意哪些安全问题?
结论
首次接入支付功能未必很难,但不要自行实现支付网关,也不要让银行卡号、CVV 等敏感数据经过自己的服务器。
更稳妥的做法是选择成熟的支付服务商,使用其托管收银台、官方 SDK 或前端令牌化组件。你的系统只需创建订单、发起支付、验证回调并更新业务状态。这样既能降低开发难度,也能大幅缩小 PCI DSS 合规范围。
如果项目需要直接处理银行卡数据、保存支付凭证,或者涉及大额交易和较高的业务风险,应请有支付安全经验的人员参与设计和代码审查。普通项目即使使用托管支付页面,上线前也至少要进行一次独立安全审查。
先分清接入支付和实现网关
通常所说的“添加 payment gateway”,指的是接入第三方支付服务,而不是从零开发一个网关。
典型的支付流程如下:
- 用户在你的系统中创建订单。
- 服务器根据数据库中的商品价格计算应付金额。
- 服务器调用支付服务商 API 创建支付会话。
- 用户跳转到服务商托管的支付页面,或通过令牌化组件输入支付信息。
- 支付服务商通过 webhook 通知你的服务器支付结果。
- 服务器验证 webhook,并以幂等方式更新订单。
- 用户返回结果页面,但不能把页面跳转本身作为支付成功的依据。
只要敏感支付数据始终由支付服务商处理,开发工作就主要集中在订单状态和接口安全上,整体难度可控。如果自行接收银行卡号、处理 3-D Secure、管理拒付或保存支付凭证,复杂度和责任都会明显增加。
不要让敏感支付数据进入自己的系统
应优先采用这些集成方式:
- 托管收银台或支付跳转页面;
- 服务商提供的 iframe、支付组件或移动端 SDK;
- 由服务商生成的一次性 token;
- 保存银行卡时,使用服务商返回的 customer ID 或 payment method token。
不要自行存储以下数据:
- 完整银行卡号;
- CVV/CVC;
- 磁道数据;
- 支付账户密码;
- 未加密的支付访问令牌。
数据库经过加密,也不代表这些数据可以被安全、合规地保存。尤其不能为了“以后退款方便”而保留 CVV。退款通常应使用原交易 ID 调用服务商的退款 API。
采用托管方案并不等于完全免除 PCI DSS 责任。具体适用范围取决于支付方式、部署结构、地区、收单机构和服务商要求,应以当前有效的合规文件和专业意见为准。
金额必须由服务器计算
不要相信浏览器提交的金额、币种、折扣或商品名称,攻击者可以直接修改 HTTP 请求。
错误做法:
const { productId, amount } = req.body;
await paymentProvider.createPayment({
amount,
currency: "CNY"
});
更安全的做法是只接收商品或订单标识,再根据服务器中的可信数据计算价格:
app.post("/api/checkout", requireLogin, async (req, res) => {
const { orderId } = req.body;
const order = await db.orders.findForUser(orderId, req.user.id);
if (!order) {
return res.status(404).json({ error: "Order not found" });
}
if (order.status !== "pending") {
return res.status(409).json({ error: "Order cannot be paid" });
}
const amount = calculateAmountFromTrustedData(order.items);
const session = await paymentProvider.createCheckoutSession({
amount,
currency: order.currency,
reference: order.id,
idempotencyKey: `checkout:${order.id}`
});
await db.orders.savePaymentSession(order.id, session.id);
res.json({ checkoutUrl: session.url });
});
这里的 paymentProvider、方法名称和参数只是结构示例。实际代码必须使用服务商的官方 SDK,并严格遵循其签名、金额单位和幂等机制。
金额最好用最小货币单位的整数表示,例如分,而不是浮点数:
const amountInCents = 1999;
不要把 19.99 * 100 之类的浮点运算结果作为最终结算依据。部分货币没有两位小数,具体规则应由币种配置决定。
webhook 才是支付状态的可信来源
用户被重定向到 /payment/success,不代表订单已经支付。返回地址可以伪造,用户也可能在完成支付后直接关闭页面。
订单状态应根据验证通过的 webhook 更新。处理 webhook 时至少要做到:
- 使用原始请求体校验签名;
- 校验时间戳,并限制可接受的时间偏差;
- 防止重复处理同一事件;
- 检查支付对象是否属于当前商户;
- 核对订单号、金额和币种;
- 只允许合法的状态转换;
- 尽快返回响应,把耗时业务交给队列处理。
示意代码如下:
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = paymentProvider.verifyWebhook({
rawBody: req.body,
signature: req.headers["payment-signature"],
secret: process.env.PAYMENT_WEBHOOK_SECRET
});
} catch {
return res.sendStatus(400);
}
const alreadyProcessed = await db.webhookEvents.exists(event.id);
if (alreadyProcessed) {
return res.sendStatus(200);
}
await db.transaction(async (tx) => {
await tx.webhookEvents.insert(event.id);
if (event.type !== "payment.succeeded") {
return;
}
const order = await tx.orders.lockById(event.data.reference);
if (!order) {
throw new Error("Unknown order");
}
if (
event.data.amount !== order.expectedAmount ||
event.data.currency !== order.currency
) {
throw new Error("Payment amount mismatch");
}
if (order.status === "pending") {
await tx.orders.markPaid(order.id, event.data.paymentId);
}
});
res.sendStatus(200);
}
);
不同服务商对 webhook 原始报文、签名头、时间戳和重试机制有不同规定,不能直接套用一套通用的 HMAC 逻辑。应优先使用官方 SDK 提供的验证函数,并确认 Web 框架没有在验签前改写请求体。
幂等性不能省
网络超时不等于支付失败。客户端、服务器和支付服务商都可能重试同一个请求。没有幂等设计,就可能出现重复扣款、重复发货或重复退款。
以下环节都需要实现幂等:
- 创建支付;
- 处理 webhook;
- 确认订单;
- 发货或开通服务;
- 退款。
可以为每个业务动作分配一个稳定且唯一的键:
checkout:order_123
fulfillment:order_123
refund:payment_456:request_1
数据库中也应建立唯一约束。不能只在应用层先查询再插入,因为多个并发请求可能同时通过检查。
CREATE UNIQUE INDEX webhook_events_event_id_uq
ON webhook_events(event_id);
CREATE UNIQUE INDEX payments_provider_payment_id_uq
ON payments(provider_payment_id);
严格设计订单状态机
不要只保存一个 paid: true。支付可能处于等待、授权、扣款、失败、取消、退款、部分退款或争议等不同状态。
可以根据业务需要设计类似的状态:
pending
payment_processing
authorized
paid
payment_failed
cancelled
partially_refunded
refunded
disputed
状态之间不能随意转换。例如:
const allowedTransitions = {
pending: ["payment_processing", "cancelled"],
payment_processing: ["authorized", "paid", "payment_failed"],
authorized: ["paid", "cancelled"],
paid: ["partially_refunded", "refunded", "disputed"],
partially_refunded: ["refunded", "disputed"]
};
还要区分“授权”和“扣款”。某些支付方式会先冻结额度,之后才执行 capture。看到授权成功,不能立即把交易视为最终到账。
常规 Web 安全仍然不能少
支付服务商只能保护支付环节,无法修复你的账户系统或订单接口。至少要落实这些措施:
- 全站使用 HTTPS,并正确配置 TLS;
- 为后台账户启用多因素认证;
- 使用安全的 session cookie;
- 防范 SQL 注入、XSS、CSRF 和越权访问;
- 对登录、下单、优惠券和支付接口实施速率限制;
- 对管理、退款和修改收款配置等操作执行最小权限原则;
- 退款等高风险操作需要重新认证或双人审批;
- 定期更新依赖并修复安全漏洞;
- 对外部 URL 跳转使用白名单,防止开放重定向;
- 不要把密钥写入前端代码、Git 仓库或日志;
- 生产环境和测试环境使用不同的密钥;
- 定期轮换 API key 和 webhook secret。
支付接口的身份验证和授权是两回事。用户已经登录,不代表他有权支付、查询或退款任意订单。每次访问都必须检查订单归属。
不要在日志中泄露支付信息
排查支付问题需要审计记录,但日志本身也可能成为数据泄露源。
建议记录以下信息:
- 内部订单 ID;
- 服务商支付 ID;
- webhook event ID;
- 操作类型和结果;
- 状态变化;
- 操作者或系统身份;
- 时间和追踪 ID。
以下内容应避免记录:
- 完整银行卡号和 CVV;
- API key、签名密钥;
- 完整的
Authorization请求头; - 未脱敏的个人信息;
- webhook 完整请求体,除非已经确认其中不含敏感数据,并设置了合适的访问控制和保存期限。
日志应具备防篡改能力,并限制开发、客服和运维人员的访问范围。退款、人工改价、修改收款账户和补发商品等操作尤其需要保留审计记录。
防范业务层攻击
实际损失未必来自支付网关被破解,也可能源于业务规则漏洞,例如:
- 修改商品数量或单价;
- 重复使用优惠券;
- 利用并发请求超卖;
- 支付低价订单后替换商品;
- 重放 webhook;
- 伪造退款请求;
- 使用被盗账户购买可立即转移的虚拟商品;
- 支付失败后仍然触发发货;
- 利用汇率或小数舍入差异少付金额。
因此,发货前应再次核对:
支付状态 == 最终成功
支付金额 == 订单应付金额
支付币种 == 订单币种
支付商户 == 当前商户
支付关联订单 == 当前订单
订单内容自创建支付后未被修改
订单尚未发货
对于虚拟商品、礼品卡、充值等高风险业务,还应考虑设备、IP、账户历史、交易频率和异常金额等风控信号。具体规则要结合业务的误报成本来设计,不能简单依靠封禁某个 IP。
测试不能只测“支付成功”
应使用服务商提供的测试环境覆盖以下情况:
- 支付成功和失败;
- 用户取消支付;
- 支付超时;
- 强认证或额外验证;
- webhook 延迟、乱序和重复;
- webhook 验签失败;
- 创建支付请求超时后重试;
- 金额或币种不一致;
- 授权成功但扣款失败;
- 全额和部分退款;
- 同一订单被并发支付;
- 重复点击支付按钮;
- 服务商 API 暂时不可用;
- 用户支付成功但没有返回网站。
不要在测试代码中写死生产密钥,也不要使用真实银行卡数据进行测试。上线前还要确认测试模式和生产模式已经完全隔离。
是否需要外部安全审查
出现以下情况时,建议进行专业审查:
- 银行卡数据会进入你的服务器;
- 需要自行保存支付凭证;
- 业务涉及较高金额或大量交易;
- 有复杂的订阅、分账、余额或提现功能;
- 涉及多个国家或地区的支付法规;
- 团队此前没有支付系统经验;
- 客户合同、保险或收单机构要求审计;
- 系统一旦出错,就会立即交付无法追回的资产。
即使只是接入简单的托管收银台,也可以安排一次针对性审查,重点检查支付流程、webhook 验签、幂等性、权限控制、密钥管理和订单状态机。这通常比笼统地“看看代码是否安全”更有针对性。
外部审查不能代替持续维护。上线后仍要更新依赖、轮换密钥、监控异常并做好事件响应。
推荐的实施顺序
- 根据地区、币种、退款和结算需求,选择合规的支付服务商。
- 优先使用托管收银台或官方令牌化组件。
- 画出订单、支付、退款和发货的状态转换。
- 由服务器计算金额并创建支付会话。
- 使用官方 SDK 验证 webhook。
- 为支付、回调、发货和退款建立幂等机制。
- 配置密钥管理、权限控制、审计日志和监控告警。
- 在测试环境中覆盖失败、超时、重复和并发场景。
- 根据数据流确认当前适用的 PCI DSS 和当地监管要求。
- 上线前进行独立代码审查和支付流程安全评估。
最重要的是缩小责任边界。让专业支付服务商处理支付凭证,你的系统只负责可信金额计算、订单状态、回调验证和业务授权。做到这一点,首次接入支付功能就不会演变成一项难以控制的安全工程。
备注:内容仅供参考。
