苹果支付V2与V1解析区别:为何PHP不能直接Base64解码

Jasper
Jasper
发布时间:2026-09-20 | 4人浏览过
原创

如果 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}

先记住一个关键区别:“Base64 解码成功”只代表把编码后的字节还原出来;“JWS 验签成功”才代表你可以信任这段数据确实来自苹果,并且没有被修改。

一、这里的 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 的加密方式 不是
SVG 配图 1
图 1:V1 的主体是直接可读的 JSON;V2 的核心数据被放进 JWS 格式的 signedPayload。

二、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 通知至少存在三层处理关系:

  1. HTTP Body 是 JSON;
  2. JSON 中取出 signedPayload;
  3. 把 signedPayload 当成 JWS 拆成 header、payload、signature。

苹果官方明确说明,读取 V2 通知正文时应当对 JWS 的 payload 部分进行 Base64URL 解码,而不是把整个 signedPayload 当成一段 Base64。:contentReference[oaicite:2]{index=2}

SVG 配图 2
图 2:真正应该解码的是 JWS 中的 header 或 payload 段,而不是整个 signedPayload 字符串。

四、为什么 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;
}

这里做了三件事情:

  1. 把 Base64URL 的 - 和 _ 转回标准 Base64 的 + 和 /;
  2. 根据长度补齐缺少的 =;
  3. 使用严格模式进行 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,
    ];
}
SVG 配图 3
图 3:从 HTTP Body 到可用业务数据,中间至少要经过外层 JSON、JWS 拆分、Base64URL 解码和签名验证。

六、完整解析一个 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}

SVG 配图 4
图 4:Base64URL 负责“读取”,JWS 验签和证书链验证负责判断“这份数据是否可信”。

九、生产环境应该怎样处理 JWS 验签

如果只是排查为什么 PHP 解不出来,前面的 Base64URL 函数已经足够定位问题。但生产环境还应当把完整流程补齐。

建议把服务端处理拆成两个明确阶段。

阶段一:解析

  1. 读取 php://input;
  2. 解析外层 JSON;
  3. 取出 signedPayload;
  4. 检查 JWS 是否正好有三段;
  5. Base64URL 解码 header;
  6. Base64URL 解码 payload。

阶段二:验证

  1. 读取 JWS header 中的签名算法和 x5c;
  2. 检查证书链,而不是只信任 x5c[0] 中传进来的证书;
  3. 确认信任链最终指向可信的 Apple 根证书;
  4. 验证 JWS 的签名;
  5. 检查应用相关信息,例如预期的 bundleId、环境等;
  6. 如果存在 signedTransactionInfo,对它独立验签;
  7. 如果存在 signedRenewalInfo,同样独立验签;
  8. 验签和业务条件都满足后,再修改订单、订阅或退款状态。

不要简单地从 JWS header 中取出 x5c[0],直接把它当成“可信苹果公钥”。因为 header 本身也属于客户端收到的数据的一部分,如果不验证证书链,攻击者可以构造自己的证书和自己的签名,让“数学意义上的验签”通过,却并不能证明签名来自苹果。

苹果官方说明,V2 JWS header 中的 x5c 提供对应签名密钥的 X.509 证书链,证书链包含签名证书、Apple 中间证书以及 Apple 根证书关系,需要通过受信任的 Apple 根证书建立验证链。:contentReference[oaicite:5]{index=5}

如果项目使用第三方 PHP JWS/JWT 库,要确认它解决的不只是“ECDSA 签名计算”,还要看你的实现是否正确处理了 Apple 的证书链验证。仅仅调用一个 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 能看到但验签失败,是签名或证书验证问题;
  • 验签通过但订单没有变化,才进入业务逻辑排查。

十四、最终应该记住什么

SVG 配图 5
图 5:V2 的关键不是把 base64_decode 换个写法,而是整个数据处理模型从普通 JSON 变成了 JWS。

简单总结: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 签名数据。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表