苹果支付V2与V1解析区别:为何PHP不能直接Base64解码
如果 PHP 接收苹果支付相关的 V2 通知后,拿到 signedPayload 直接执行 base64_decode(),通常得不到预期的 JSON,问题并不是苹果把 JSON “多加密了一层”,而是V2 与 V1 的数据结构已经完全不同。这里所说的 V1、V2,准确地说是苹果的 App Store Server Notifications,也就是 App Store 内购服务器通知,而不是 Apple Pay 银行卡支付 Token 的 EC_v1、RSA_v1。
V1 的 HTTP Body 本身就是普通 JSON;V2 的 HTTP Body 主要包含一个 signedPayload,它是 JWS,格式为 header.payload.signature。其中每一段使用的是 Base64URL,而不是普通 Base64。因此正确流程是:先解析外层 JSON,再按英文句点拆分 JWS,对其中的 payload 做 Base64URL 解码,解析 JSON,然后继续处理里面可能存在的 signedTransactionInfo 和 signedRenewalInfo。生产环境还必须完成 JWS 签名验证,而不能只做解码。Apple 当前已经将 V1 标记为弃用,并建议新的服务端实现使用 V2。:contentReference[oaicite:0]{index=0}
一、这里的 V1 和 V2 到底指什么
很多项目习惯把 App Store 内购也简称为“苹果支付”,于是代码里经常出现 apple_pay_v1、apple_pay_v2 这样的命名。但从苹果官方接口名称来看,本文讨论的是 App Store Server Notifications V1/V2。
这一点需要先确认,因为 Apple Pay 本身也存在带有 EC_v1、RSA_v1 字样的支付 Token。如果把两套协议混在一起,会导致完全错误的解析方式。
| 名称 | 主要场景 | 本文是否讨论 |
|---|---|---|
| App Store Server Notifications V1 | App Store 内购、订阅状态变化的旧版服务器通知 | 是 |
| App Store Server Notifications V2 | 新版 App Store 内购服务器通知,使用 JWS | 是 |
| Apple Pay EC_v1 / RSA_v1 | Apple Pay 银行卡支付 Token 的加密方式 | 不是 |
二、V1 为什么看起来容易解析
V1 通知的请求体就是一个 JSON 对象。苹果官方的旧版结构中可以出现 notification_type、environment、original_transaction_id、unified_receipt 等字段。:contentReference[oaicite:1]{index=1}
因此 PHP 收到 V1 请求时,入口代码通常比较直接:
<?php
$rawBody = file_get_contents('php://input');
try {
$notification = json_decode(
$rawBody,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException $e) {
http_response_code(400);
exit('invalid json');
}
$notificationType = $notification['notification_type'] ?? null;
$environment = $notification['environment'] ?? null;
$unifiedReceipt = $notification['unified_receipt'] ?? null;
这里没有“先拆 JWS”这一步,所以从 V1 升级 V2 时,如果只是把旧代码里的字段名改一改,通常很快就会遇到问题。
V2 请求体大致变成了这样的结构:
{
"signedPayload": "xxxxx.yyyyy.zzzzz"
}
真正的通知类型、环境、交易信息等内容,不再直接出现在这个最外层 JSON 中。
三、V2 的 signedPayload 实际是什么
signedPayload 是一段 JWS,也就是 JSON Web Signature。苹果官方说明它由三个经过 Base64URL 编码的部分组成,并使用英文句点 . 分隔:
BASE64URL(header).BASE64URL(payload).BASE64URL(signature)
也就是说,一个 V2 通知至少存在三层处理关系:
- HTTP Body 是 JSON;
- JSON 中取出
signedPayload; - 把
signedPayload当成 JWS 拆成 header、payload、signature。
苹果官方明确说明,读取 V2 通知正文时应当对 JWS 的 payload 部分进行 Base64URL 解码,而不是把整个 signedPayload 当成一段 Base64。:contentReference[oaicite:2]{index=2}
四、为什么 PHP 不能直接 base64_decode(signedPayload)
最常见的错误代码是下面这样:
<?php
$data = base64_decode($request['signedPayload']);
var_dump($data);
这段代码的问题不止一个。
1. signedPayload 根本不是“一段 Base64”
它由三个部分组成:
header.payload.signature
中间两个 . 是 JWS 的结构分隔符,并不是 Base64 数据的一部分。因此必须先执行:
$parts = explode('.', $signedPayload);
if (count($parts) !== 3) {
throw new RuntimeException('signedPayload 不是合法的 JWS Compact 格式');
}
[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;
2. JWS 使用的是 Base64URL,不是标准 Base64
普通 Base64 和 Base64URL 很像,但字符表存在关键差别:
| 内容 | 普通 Base64 | Base64URL |
|---|---|---|
| 字符 62 | + |
- |
| 字符 63 | / |
_ |
| 结尾填充 | 经常包含 = |
JWS Compact 中通常省略 = |
所以不能简单认为:
base64_decode($encodedPayload)
就是完整、可靠的 Base64URL 解码方式。
特别是 PHP 使用非严格模式时,遇到不属于标准 Base64 字符表的内容可能产生令人迷惑的结果:代码没有立即报错,但得到的字节已经不是你期望的数据。因此不要把“函数返回了字符串”当成“解码正确”。
3. 解码不等于解密,也不等于验签
JWS 的 payload 并不是因为“加密”才看不懂。Base64URL 本质上仍然只是编码。只要格式正确,任何人都可以读取 payload。
JWS 的安全重点在数字签名:你要验证 header 和 payload 确实由受信任的苹果证书对应私钥签发,并且传输过程中没有被改动。
五、PHP 正确的 Base64URL 解码函数
如果目的只是查看 JWS 中的数据,建议单独封装一个 Base64URL 解码函数,而不是在业务代码里到处直接调用 base64_decode()。
<?php
declare(strict_types=1);
function base64UrlDecode(string $value): string
{
// Base64URL 转换回普通 Base64 字符表
$value = strtr($value, '-_', '+/');
// JWS 通常省略 "=" 填充,需要按 4 的倍数补齐
$remainder = strlen($value) % 4;
if ($remainder !== 0) {
$value .= str_repeat('=', 4 - $remainder);
}
// strict=true,避免悄悄忽略非法字符
$decoded = base64_decode($value, true);
if ($decoded === false) {
throw new RuntimeException('Base64URL 解码失败');
}
return $decoded;
}
这里做了三件事情:
- 把 Base64URL 的
-和_转回标准 Base64 的+和/; - 根据长度补齐缺少的
=; - 使用严格模式进行 Base64 解码。
接下来就可以专门写一个用于查看 JWS 内容的函数。
<?php
declare(strict_types=1);
function base64UrlDecode(string $value): string
{
$value = strtr($value, '-_', '+/');
$remainder = strlen($value) % 4;
if ($remainder !== 0) {
$value .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode($value, true);
if ($decoded === false) {
throw new RuntimeException('Base64URL 解码失败');
}
return $decoded;
}
/**
* 仅解析 JWS 内容,不验证签名。
*
* 注意:此函数适合日志、调试和理解数据结构,
* 不应把返回结果直接作为支付业务可信数据。
*/
function decodeJwsWithoutVerification(string $jws): array
{
$parts = explode('.', $jws);
if (count($parts) !== 3) {
throw new RuntimeException('JWS 格式错误,应当包含三个部分');
}
[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;
$headerJson = base64UrlDecode($encodedHeader);
$payloadJson = base64UrlDecode($encodedPayload);
$header = json_decode(
$headerJson,
true,
512,
JSON_THROW_ON_ERROR
);
$payload = json_decode(
$payloadJson,
true,
512,
JSON_THROW_ON_ERROR
);
return [
'header' => $header,
'payload' => $payload,
'encodedSignature' => $encodedSignature,
];
}
六、完整解析一个 V2 请求的 PHP 示例
下面把前面的步骤组合起来。这个示例解决的是如何正确读取和展开 V2 数据结构,并故意把“解析”和“信任”区分开。
<?php
declare(strict_types=1);
function base64UrlDecode(string $value): string
{
$value = strtr($value, '-_', '+/');
$remainder = strlen($value) % 4;
if ($remainder !== 0) {
$value .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode($value, true);
if ($decoded === false) {
throw new RuntimeException('Base64URL 解码失败');
}
return $decoded;
}
function decodeJwsWithoutVerification(string $jws): array
{
$parts = explode('.', $jws);
if (count($parts) !== 3) {
throw new RuntimeException('非法 JWS:必须正好包含三段');
}
[$encodedHeader, $encodedPayload, $encodedSignature] = $parts;
$header = json_decode(
base64UrlDecode($encodedHeader),
true,
512,
JSON_THROW_ON_ERROR
);
$payload = json_decode(
base64UrlDecode($encodedPayload),
true,
512,
JSON_THROW_ON_ERROR
);
return [
'header' => $header,
'payload' => $payload,
'signature' => $encodedSignature,
];
}
try {
// 1. 读取苹果 POST 的原始请求体
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
throw new RuntimeException('请求体为空');
}
// 2. 解析最外层 JSON
$request = json_decode(
$rawBody,
true,
512,
JSON_THROW_ON_ERROR
);
// 3. 获取 signedPayload
$signedPayload = $request['signedPayload'] ?? null;
if (!is_string($signedPayload) || $signedPayload === '') {
throw new RuntimeException('缺少 signedPayload');
}
// 4. 解析最外层 JWS
// 注意:这里只解析,没有验证签名
$notificationJws = decodeJwsWithoutVerification($signedPayload);
$notification = $notificationJws['payload'];
$notificationType = $notification['notificationType'] ?? null;
$subtype = $notification['subtype'] ?? null;
$notificationUUID = $notification['notificationUUID'] ?? null;
$signedDate = $notification['signedDate'] ?? null;
// 5. 某些通知具有 data 对象
$data = $notification['data'] ?? null;
$transaction = null;
$renewal = null;
if (is_array($data)) {
// 6. signedTransactionInfo 本身又是一个 JWS
if (
isset($data['signedTransactionInfo']) &&
is_string($data['signedTransactionInfo'])
) {
$transactionJws = decodeJwsWithoutVerification(
$data['signedTransactionInfo']
);
$transaction = $transactionJws['payload'];
}
// 7. signedRenewalInfo 也可能是一个独立 JWS
if (
isset($data['signedRenewalInfo']) &&
is_string($data['signedRenewalInfo'])
) {
$renewalJws = decodeJwsWithoutVerification(
$data['signedRenewalInfo']
);
$renewal = $renewalJws['payload'];
}
}
// 到这里仅表示格式解析成功。
// 生产环境必须在改变订单、会员、退款等状态前完成 JWS 验签。
$result = [
'notificationType' => $notificationType,
'subtype' => $subtype,
'notificationUUID' => $notificationUUID,
'signedDate' => $signedDate,
'transaction' => $transaction,
'renewal' => $renewal,
];
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
$result,
JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES |
JSON_PRETTY_PRINT |
JSON_THROW_ON_ERROR
);
} catch (Throwable $e) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'error' => 'invalid_notification',
'message' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE);
}
如果通知属于内购交易类型,V2 解码后的 data 中通常还会出现 signedTransactionInfo;对于自动续订订阅,还可能包含 signedRenewalInfo。苹果同样将这些字段定义成 JWS,因此它们还需要再次进行 JWS 解析和验证。:contentReference[oaicite:3]{index=3}
七、为什么解析一次 signedPayload 还不够
这是 V2 接入时第二个很容易忽略的地方。
假设最外层 signedPayload 已经解码,你可能得到类似下面的结构。以下仅用于说明层级,并非一条真实苹果通知:
{
"notificationType": "DID_RENEW",
"subtype": "...",
"notificationUUID": "...",
"data": {
"bundleId": "com.example.app",
"environment": "Sandbox",
"signedTransactionInfo": "aaaa.bbbb.cccc",
"signedRenewalInfo": "dddd.eeee.ffff"
}
}
这里的 signedTransactionInfo 不是普通 JSON 对象,而是第二个 JWS:
signedPayload
↓
通知 payload
↓
data
├── signedTransactionInfo
│ ↓
│ header.payload.signature
│ ↓
│ 交易信息
│
└── signedRenewalInfo
↓
header.payload.signature
↓
续订信息
因此不能写成:
$transaction = json_decode(
$notification['data']['signedTransactionInfo'],
true
);
也不能直接:
$transaction = json_decode(
base64_decode($notification['data']['signedTransactionInfo']),
true
);
正确思路仍然是:拆成三段 → Base64URL 解码第二段 → JSON 解析 → 验签。
八、Base64URL 解开以后,为什么还不能直接更新订单
因为 Base64URL 没有提供真实性保护。
假设接口代码只完成下面这些工作:
$decoded = decodeJwsWithoutVerification($signedPayload);
$type = $decoded['payload']['notificationType'] ?? null;
if ($type === 'DID_RENEW') {
// 直接给用户续期
}
从数据处理角度看,它能够“读出字段”,但安全逻辑是不完整的。攻击者理论上也可以构造一个三段式字符串,并把自己希望出现的 JSON 做成 Base64URL。
JWS 的第三段 signature 就是为了解决这个问题。
苹果的 JWS header 中包含用于验证签名的信息,其中包括 alg 以及 x5c 证书链信息。苹果官方要求使用这些信息验证 JWS,x5c 证书链最终需要建立到受信任的 Apple 根证书。:contentReference[oaicite:4]{index=4}
九、生产环境应该怎样处理 JWS 验签
如果只是排查为什么 PHP 解不出来,前面的 Base64URL 函数已经足够定位问题。但生产环境还应当把完整流程补齐。
建议把服务端处理拆成两个明确阶段。
阶段一:解析
- 读取
php://input; - 解析外层 JSON;
- 取出
signedPayload; - 检查 JWS 是否正好有三段;
- Base64URL 解码 header;
- Base64URL 解码 payload。
阶段二:验证
- 读取 JWS header 中的签名算法和
x5c; - 检查证书链,而不是只信任
x5c[0]中传进来的证书; - 确认信任链最终指向可信的 Apple 根证书;
- 验证 JWS 的签名;
- 检查应用相关信息,例如预期的
bundleId、环境等; - 如果存在
signedTransactionInfo,对它独立验签; - 如果存在
signedRenewalInfo,同样独立验签; - 验签和业务条件都满足后,再修改订单、订阅或退款状态。
不要简单地从 JWS header 中取出 x5c[0],直接把它当成“可信苹果公钥”。因为 header 本身也属于客户端收到的数据的一部分,如果不验证证书链,攻击者可以构造自己的证书和自己的签名,让“数学意义上的验签”通过,却并不能证明签名来自苹果。
苹果官方说明,V2 JWS header 中的 x5c 提供对应签名密钥的 X.509 证书链,证书链包含签名证书、Apple 中间证书以及 Apple 根证书关系,需要通过受信任的 Apple 根证书建立验证链。:contentReference[oaicite:5]{index=5}
decode() 方法,不代表整套 App Store Server Notifications V2 验证已经完成。
十、为什么有时 base64_decode() 看起来又能用
这也是实际排查中容易产生误解的地方。
某一条 JWS 的 payload 可能刚好只包含 Base64 和 Base64URL 公用的字符,而且 PHP 对缺少填充的情况也可能仍然返回结果。这时下面的代码看起来“成功了”:
$json = base64_decode($encodedPayload);
于是开发者很容易得出结论:“PHP 可以直接解。”
问题在于,这种写法并没有正确表达数据格式要求。当后续某个 payload 出现 - 或 _,或者在严格模式下处理时,行为就可能不同。
所以应该按照协议格式写代码,而不是根据几条测试数据是否刚好能够解开来判断:
$json = base64UrlDecode($encodedPayload);
这样代码含义也更明确:这里处理的是 Base64URL,而不是普通 Base64。
十一、V1 与 V2 的核心差异应该怎样理解
| 对比项 | V1 | V2 |
|---|---|---|
| 外层 HTTP Body | 直接是包含业务字段的 JSON | JSON 中主要提供 signedPayload |
| 主要通知字段 | 如 notification_type |
解开 JWS payload 后读取 notificationType、subtype 等 |
| 交易信息 | 主要围绕旧版 receipt / unified_receipt 结构 |
常见于 signedTransactionInfo JWS |
| 续订信息 | 旧版 receipt 及相关字段 | 可通过 signedRenewalInfo JWS 提供 |
| 编码处理 | 正常 JSON 解析 | JWS 各段使用 Base64URL |
| 真实性验证 | 旧版通知和 receipt 验证体系 | 核心数据使用 JWS 签名,可在服务端验证 |
| 当前状态 | Apple 已标记为 Deprecated | 新实现应采用 V2 |
苹果官方对 V2 的描述也很明确:responseBodyV2 中的 signedPayload 是由 App Store 签名的 JWS;解码其 payload 后获得通知正文,而其中的 signedTransactionInfo 和 signedRenewalInfo 又分别是可以继续解析和验证的 JWS。:contentReference[oaicite:6]{index=6}
十二、常见错误应该怎样排查
错误一:直接 base64_decode 整个 signedPayload
错误:
$payload = base64_decode($signedPayload);
原因:整个值是三段 JWS,不是一段 Base64。
正确方式:
[$header, $payload, $signature] = explode('.', $signedPayload);
$json = base64UrlDecode($payload);
错误二:拆开以后仍然直接使用普通 Base64
即使已经取到第二段,也应按 Base64URL 处理:
$payloadJson = base64UrlDecode($encodedPayload);
不要依赖测试数据“恰好没有 - 或 _”。
错误三:外层解完就找 transactionId
V2 中交易详情通常不是简单平铺在最外层通知对象中。应该检查:
$notification['data']['signedTransactionInfo']
然后再次对这个 JWS 进行解析和验证。
错误四:json_decode(signedTransactionInfo)
signedTransactionInfo 仍是类似下面的字符串:
xxxxx.yyyyy.zzzzz
它不是 JSON,因此直接 json_decode() 得不到交易对象。
错误五:能看到 JSON 就认为支付有效
这是比“解码失败”更严重的问题。Base64URL 内容本身任何人都能够生成。只有完成 JWS 签名及信任链验证之后,才能把数据作为可信的苹果通知继续处理。
错误六:把 App Store V2 与 Apple Pay EC_v1 混在一起
如果你手里的数据类似:
{
"version": "EC_v1",
"data": "...",
"signature": "...",
"header": {
"ephemeralPublicKey": "...",
"publicKeyHash": "...",
"transactionId": "..."
}
}
那么这不是本文的 App Store Server Notifications V2,而属于 Apple Pay Payment Token 的另一套加密和验证流程。Apple Pay Payment Token 中的 data 虽然也是 Base64 表示,但 Base64 解开后得到的是加密后的支付数据,还需要恢复对称密钥并进行相应的密码学解密,不能套用本文的 JWS payload 解析逻辑。:contentReference[oaicite:7]{index=7}
十三、一个更适合实际项目的处理结构
实际项目中,不建议把所有代码都放在回调 Controller 里。可以按下面的职责拆分:
AppleNotificationController
│
├── 读取 HTTP Body
│
▼
AppleNotificationParser
│
├── 解析外层 JSON
├── 拆分 JWS
└── Base64URL 解码
│
▼
AppleJwsVerifier
│
├── 验证 x5c 证书链
├── 验证 JWS 签名
└── 检查 bundleId / environment 等
│
▼
AppleTransactionHandler
│
├── 去重 notificationUUID
├── 处理交易状态
├── 处理订阅状态
└── 写入业务数据库
这样以后排查问题时会很清楚:
- JSON 都读不出来,是请求层或格式问题;
- JWS 拆不开,是结构问题;
- Base64URL 解不开,是编码问题;
- JSON 能看到但验签失败,是签名或证书验证问题;
- 验签通过但订单没有变化,才进入业务逻辑排查。
十四、最终应该记住什么
简单总结:V1 与 V2 最大的区别,不是字段换了名字,而是苹果把服务器通知的数据组织方式改成了带数字签名的 JWS。
PHP 处理 V2 时,应该按照下面的顺序理解:
HTTP POST
↓
json_decode()
↓
signedPayload
↓
按 "." 分成 header / payload / signature
↓
Base64URL 解码 payload
↓
json_decode()
↓
取得 notificationType / data 等
↓
继续解析 signedTransactionInfo / signedRenewalInfo
↓
验证每个需要信任的 JWS
↓
检查应用、环境、交易等业务条件
↓
更新订单或订阅状态
所以“为什么 PHP 不能直接 Base64 解码”的准确答案是:因为 V2 返回的 signedPayload 不是一段普通 Base64,而是由三个 Base64URL 编码部分组成的 JWS。正确做法不是直接对整个字符串调用 base64_decode(),而是先拆分 JWS,再按 Base64URL 规则解码相应部分,并在生产环境完成签名与证书链验证。
另外,Apple 已经将 App Store Server Notifications V1 标记为弃用;对于新的服务端接入,应围绕 V2 的 JWS 数据结构实现,而不应继续按照 V1 receipt 的解析思路扩展新代码。:contentReference[oaicite:8]{index=8}
本文中的 JSON 与 JWS 字符串均为结构示例,不代表真实用户订单、真实交易或真实 Apple 签名数据。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













