如何将支付页面以带 API key 认证的 API 方式开放
结论
不要让商户服务器通过 cURL 请求支付 URL,再把返回的 HTML 直接输出到商户网站。cURL 是服务器端 HTTP 客户端,无法控制用户浏览器跳转。即使支付表单来自支付网关,只要商户页面直接输出响应内容,浏览器地址栏显示的仍是商户网站域名。
用户在一个域名下看到由另一个主体提供的银行卡信息收集页面,容易产生疑虑,也可能引起浏览器或安全检测系统的警惕。不过,仅凭这一现象无法确定 Google 作出具体判定的原因。
标准流程应分为以下几个环节:
- 商户服务器使用 API key 调用“创建支付会话”API。
- API 返回一次性的托管支付页面 URL,商户服务器让用户浏览器跳转到该 URL。
- 用户在支付网关自己的 HTTPS 域名下填写银行卡信息。
- 支付完成后,网关将用户跳转回商户页面,并通过签名 webhook 向商户服务器发送最终结果。
API key 只用于服务器之间的认证,不能出现在浏览器代码、HTML、查询字符串或公开日志中。
为什么当前代码不会改变地址栏
下面这段代码运行在商户服务器上:
$response = curl_exec($ch);
请求由服务器通过 cURL 发出,并非由用户浏览器发出。设置 CURLOPT_RETURNTRANSFER => false 后,远端返回的支付页面会直接写入当前 PHP 响应。浏览器看到的仍是商户网站返回的页面,所以地址栏不会变成支付网关的 URL。
示例中使用的地址是:
http://www.xecute/pay/
支付流程必须全程使用有效的 https:// 地址,不能依赖 HTTP 临时跳转到 HTTPS。
也不要把 cURL 响应转换成 HTML 字符串后继续输出。这样做依然是在商户域名下托管或代理银行卡输入页面,容易让用户混淆页面归属,还可能引发静态资源、Cookie、CSP、SameSite 策略及表单提交地址等问题。
推荐的托管支付流程
1. 商户服务器创建支付会话
商户后端向网关提交订单信息:
POST /v1/checkout-sessions HTTP/1.1
Host: pay.example.com
Authorization: Bearer merchant_api_key
Content-Type: application/json
Idempotency-Key: 5cc7d5fd-51ef-49ad-8ee7-cc672660191e
{
"merchant_id": "merchant_123",
"order_id": "order_10001",
"amount": 12500,
"currency": "CNY",
"customer": {
"email": "customer@example.com",
"mobile": "13800000000"
},
"return_url": "https://merchant.example.com/payment/return"
}
金额最好用最小货币单位的整数表示。例如,12500 表示 125.00 CNY,这样可以避免浮点数精度问题。
API 验证 API key、商户状态、订单号、金额和回跳地址后,创建一个有效期较短且只能用于指定订单的支付会话:
{
"session_id": "cs_7df218b1",
"checkout_url": "https://pay.example.com/checkout/cs_7df218b1",
"expires_at": "2026-09-15T15:30:00Z"
}
API 不应返回银行卡表单 HTML,也不应要求调用方通过服务器端 cURL 代理该页面。
2. 将用户浏览器重定向到支付页面
商户服务器取得 checkout_url 后,向浏览器返回重定向响应:
<?php
$ch = curl_init('https://pay.example.com/v1/checkout-sessions');
$payload = [
'merchant_id' => 'merchant_123',
'order_id' => 'order_10001',
'amount' => 12500,
'currency' => 'CNY',
'customer' => [
'email' => $_POST['emailID'] ?? '',
'mobile' => $_POST['mobileNo'] ?? '',
],
'return_url' => 'https://merchant.example.com/payment/return',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PAYMENT_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . bin2hex(random_bytes(16)),
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_TIMEOUT => 15,
]);
$responseBody = curl_exec($ch);
if ($responseBody === false) {
throw new RuntimeException(curl_error($ch));
}
$statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$response = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
if ($statusCode !== 201 || empty($response['checkout_url'])) {
throw new RuntimeException('Unable to create checkout session');
}
header('Location: ' . $response['checkout_url'], true, 303);
exit;
其中最重要的设置是:
CURLOPT_RETURNTRANSFER => true
它让商户服务器读取 API 返回的 JSON,而不是把响应直接输出给浏览器。取得 checkout_url 后,服务器再通过 303 See Other 让浏览器访问支付网关托管的页面。
checkout_url 必须来自可信 API,而且只能指向支付网关自己的 HTTPS 域名。不要把用户提交的任意 URL 直接放入 Location,否则可能形成开放重定向漏洞。
3. 在支付网关域名渲染表单
浏览器访问:
https://pay.example.com/checkout/cs_7df218b1
网关根据随机会话标识读取保存在服务器端的订单信息,再渲染支付表单。API key 不能放进这个 URL。会话标识需要满足以下要求:
- 使用密码学安全的随机值,不能通过订单号简单推算。
- 设置较短的有效期。
- 与商户、订单、金额和币种绑定。
- 支付成功后立即失效,防止重复使用。
- URL 中不携带邮箱、手机号等敏感信息。
- 对重复提交进行幂等处理。
支付页面还应明确显示支付服务方、商户名称、订单号、金额和币种,方便用户确认收款对象和支付内容。
API key 应该怎样验证
API key 用于认证调用 API 的商户服务器,不能用来认证浏览器用户。建议使用标准的 Authorization 请求头:
Authorization: Bearer sk_live_xxxxxxxxx
也可以继续使用自定义请求头:
X-API-Key: sk_live_xxxxxxxxx
无论选择哪一种方式,都需要满足以下条件:
- 只通过 HTTPS 发送。
- 每个商户使用独立密钥。
- 数据库尽量保存密钥哈希,而不是可直接读取的明文。
- 支持密钥轮换、撤销和有效期管理。
- 区分测试密钥和生产密钥。
- 不在访问日志或错误日志中记录完整密钥。
- 根据密钥确定商户身份,不信任请求体中可随意填写的
merchant_id。 - 配合速率限制、异常检测和审计日志。
- 高安全等级场景可以进一步采用请求签名、mTLS 或 OAuth 2.0 client credentials。
只验证 merchantID 不能提供可靠的认证,因为攻击者通常可以猜到或获取公开的商户编号。
不要信任浏览器提交的金额
示例代码直接使用:
'amount' => $_POST['amount']
如果这个值来自用户浏览器,攻击者可以篡改请求,将订单金额改小。商户服务器应从自己的订单数据库中重新读取金额,再将其发送给支付 API:
$order = loadOrderFromDatabase($orderId);
$payload = [
'order_id' => $order->id,
'amount' => $order->amountMinor,
'currency' => $order->currency,
];
如果同一个 order_id 使用不同的金额或币种重复创建支付会话,支付网关也应拒绝请求。
回跳页面不能作为支付成功依据
用户完成支付后,可以被重定向到:
https://merchant.example.com/payment/return?session_id=cs_7df218b1
但浏览器回跳可能中断、重复执行或被伪造。商户不能因为回跳参数中出现 status=success 就直接发货。
支付网关应向商户服务器发送 webhook:
POST /webhooks/payment HTTP/1.1
Host: merchant.example.com
Content-Type: application/json
Payment-Signature: t=1789461000,v1=...
{
"event_id": "evt_8147",
"type": "payment.succeeded",
"data": {
"session_id": "cs_7df218b1",
"order_id": "order_10001",
"amount": 12500,
"currency": "CNY",
"transaction_id": "txn_99102"
}
}
商户验证 webhook 签名、时间戳、事件 ID、金额和币种后,才能把订单更新为已支付。网关需要支持 webhook 重试,商户端则要按 event_id 或 transaction_id 进行幂等处理。
如果 webhook 尚未到达,商户服务器也可以使用 API key 查询支付状态,但不能让浏览器自行声明支付结果。
是否可以使用 iframe
从技术上说,可以把托管支付页放入 iframe,但这种方式通常不如顶层跳转直观。用户在 iframe 中填写银行卡信息时,地址栏显示的仍是商户域名,很难确认究竟由谁处理支付信息。iframe 还可能带来内容嵌入、安全策略和第三方 Cookie 等问题。
如果业务必须使用嵌入式支付组件,应专门设计 iframe 或 hosted fields 集成方案,而不是直接嵌入完整支付页面。至少需要做到:
- 通过 CSP
frame-ancestors等方式明确允许哪些商户域名嵌入页面。 - 使用精确的
postMessageorigin 校验来交换状态。 - 不向父页面发送 API key 或敏感支付信息。
- 防范点击劫持、覆盖层和恶意脚本。
- 评估这种集成方式对 PCI DSS 范围的影响。
对通用托管支付服务来说,顶层 HTTPS 重定向通常更便于说明、实施和审计。
关于 phishing 警告
当前加载方式可能增加误判风险。用户停留在商户域名,却看到另一个支付服务提供的银行卡表单,页面归属并不清楚。不过,这不足以证明它就是 Google 标记页面的唯一原因。
还需要检查以下事项:
- 支付域名和商户域名是否都启用了有效的 HTTPS。
- 页面中是否存在 HTTP 混合内容。
- 域名是否曾被入侵、托管恶意文件或发生异常跳转。
- 页面上的品牌、公司信息、隐私政策和联系方式是否完整。
- 是否存在隐藏 iframe、混淆脚本或来自不可信域名的资源。
- 支付页面是否模仿银行或其他机构的登录界面。
- 域名信誉、DNS、证书链和部署环境是否正常。
- Google Search Console 或 Safe Browsing 是否提供了具体的安全问题说明。
确认问题已经清除后,可以通过相应的站长或安全平台申请复审。但通过复审并不代表支付架构本身已经安全。
推荐的接口边界
系统最终可以划分为三个端点:
POST /v1/checkout-sessions
由服务器调用,使用 API key 创建支付会话,并返回 checkout_url。
GET /checkout/{session_id}
由用户浏览器访问,不使用 API key,用于显示托管支付页面。
POST /webhooks/payment
由支付网关通知商户服务器,使用签名验证,不依赖浏览器回跳。
这样可以分别处理 API 认证、浏览器导航和支付结果通知,让支付页面保持在可信的网关域名下,同时避免 API key 暴露给客户端。PCI Compliant environment 只是基础条件,密钥管理、订单完整性、会话安全、日志脱敏、漏洞管理和 webhook 验证仍需分别落实。
备注:内容仅供参考。
