PHP使用firebase/PHP-jwt解析苹果支付V2 JWS通知

Ada
Ada
发布时间:2026-09-23| 7人浏览过
原创

App Store Server Notifications V2 与旧版通知最大的区别之一,是苹果不再直接把一组可以信任的交易字段放进普通 JSON 中,而是把通知主体放在 signedPayload 这个 JWS 字符串里。PHP 服务端可以使用 firebase/php-jwt 完成 ES256 签名验证和 Payload 解析,但真正安全的处理不能只调用一次 JWT::decode():还要验证 JWS Header 中的 x5c 证书链是否最终指向你本地信任的 Apple 根证书,然后再分别验证 signedTransactionInfo 和可能存在的 signedRenewalInfo。

也就是说,正确的思路不是“把 JWS 中间一段 Base64 解码出来”,而是“建立对 Apple 证书链的信任,再验证 JWS 签名,最后才使用其中的交易数据”。本文就按照这个顺序完成一套 PHP 8+ 的实现。

PHP使用firebase/PHP-jwt解析苹果支付V2 JWS通知
图 1:PHP 处理 App Store Server Notifications V2 的完整顺序。

一、先弄清 signedPayload 到底是什么

苹果发送 V2 通知时,请求体最外层通常只有一个真正需要处理的字段:

{
    "signedPayload": "JWS字符串"
}

这个字符串采用 JWS Compact Serialization 格式,由三个使用 Base64URL 编码的部分组成:

Base64URL(Header).Base64URL(Payload).Base64URL(Signature)

中间的 Payload 确实可以直接 Base64URL 解码成 JSON,但这样做只能叫“读取数据”,不能叫“验证通知”。攻击者完全可以自己构造一个 Payload,把 transactionId、productId、expiresDate 改成任意内容。

真正决定这份数据是否可信的是第三段 Signature,以及 Header 里的 x5c 证书链。

PHP使用firebase/PHP-jwt解析苹果支付V2 JWS通知
图 2:V2 通知不是简单 JSON,而是外层 JWS 中继续嵌套交易和续订 JWS。

二、准备 PHP 环境和 firebase/php-jwt

本文代码采用 PHP 8+,并需要启用 OpenSSL 扩展。项目中通过 Composer 安装 firebase/php-jwt:

在项目根目录执行:

composer require firebase/php-jwt

确认扩展已经启用:

php -m | grep -i openssl

能够看到类似下面的输出即可:

openssl

firebase/php-jwt 已经能够验证 ES256 JWS,所以不需要自己实现 ECDSA 签名算法。真正需要补上的,是 Apple x5c 证书链验证。

一个容易误解的地方: JWT::decode() 可以判断“这个 JWS 是否由某个公钥签名”,但如果这个公钥本身就是从未验证的 x5c[0] 中取得的,那么攻击者也可以自己生成证书、私钥和 JWS。因此,必须先证明这张叶子证书属于一条受 Apple 根证书信任的证书链。

三、准备本地信任的 Apple 根证书

JWS Header 中的 x5c 会携带证书链,但不能因为第三张证书自称是“Root”就直接相信它。信任锚必须来自服务器自己保存的 Apple 根证书。

正确做法是从 Apple PKI 官方页面下载 Apple 发布的根证书,将需要信任的根证书放到项目之外或只读配置目录,例如:

project/
├── certs/
│   └── apple-root-ca.pem
├── public/
│   └── apple-notify.php
├── src/
│   └── AppleJwsVerifier.php
├── vendor/
└── composer.json

如果下载到的是 DER 编码的 .cer 文件,可以使用 OpenSSL 转成 PEM:

openssl x509 \
  -inform DER \
  -in downloaded-apple-root.cer \
  -out certs/apple-root-ca.pem

如果 Apple 后续使用多个受信任根证书,可以把多个 PEM 文件一起加入信任列表,而不是把根证书写死在 PHP 源码里。

PHP使用firebase/PHP-jwt解析苹果支付V2 JWS通知
图 3:本地 Apple Root CA 是信任起点,x5c 中的证书只能作为待验证数据。

四、编写 AppleJwsVerifier 验证类

下面建立 src/AppleJwsVerifier.php。它完成几件事:

  • 拆分 JWS 的 Header、Payload、Signature;
  • 限定算法必须为 ES256;
  • 读取并转换 x5c 三张证书;
  • 确认根证书属于服务器本地信任列表;
  • 检查 Leaf → Intermediate → Root 的签名关系;
  • 检查 Apple 证书相关扩展;
  • 提取 Leaf 公钥交给 firebase/php-jwt;
  • 只有 JWS 验签成功后才返回 Payload。

Apple 当前官方验证实现要求 JWS 的证书链长度为三张,并检查 Apple 签名证书及 WWDR 中间证书相关扩展。下面的 PHP 代码按这个思路实现基本离线验证。

<?php

declare(strict_types=1);

namespace App\Security;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use RuntimeException;
use Throwable;

final class AppleJwsVerifier
{
    private const ALGORITHM = 'ES256';

    /**
     * Apple App Store JWS signing certificate extension.
     */
    private const LEAF_EXTENSION_OID =
        '1.2.840.113635.100.6.11.1';

    /**
     * Apple Worldwide Developer Relations intermediate extension.
     */
    private const INTERMEDIATE_EXTENSION_OID =
        '1.2.840.113635.100.6.2.1';

    /**
     * @param string[] $trustedRootFiles
     */
    public function __construct(
        private readonly array $trustedRootFiles
    ) {
        if ($trustedRootFiles === []) {
            throw new RuntimeException(
                '至少需要配置一个 Apple 根证书'
            );
        }
    }

    /**
     * 验证一条 Apple JWS,并返回已经验签的 Payload。
     *
     * @return array<string, mixed>
     */
    public function verify(string $jws): array
    {
        [$encodedHeader, $encodedPayload] =
            $this->splitJws($jws);

        $header = $this->decodeJsonPart($encodedHeader);

        if (($header['alg'] ?? null) !== self::ALGORITHM) {
            throw new RuntimeException(
                '不接受非 ES256 的 Apple JWS'
            );
        }

        $x5c = $header['x5c'] ?? null;

        if (!is_array($x5c) || count($x5c) !== 3) {
            throw new RuntimeException(
                'Apple JWS x5c 证书链长度异常'
            );
        }

        $certificates = [];

        foreach ($x5c as $certificate) {
            if (!is_string($certificate)) {
                throw new RuntimeException(
                    'x5c 证书格式错误'
                );
            }

            $certificates[] =
                $this->x5cToPem($certificate);
        }

        /*
         * signedDate 此时尚未验签。
         *
         * 它只能用来选择证书有效期检查的时间点,
         * 绝不能在这里拿它做订单、权限等业务判断。
         */
        $previewPayload =
            $this->decodeJsonPart($encodedPayload);

        $effectiveTime =
            $this->getEffectiveTime($previewPayload);

        $publicKeyPem =
            $this->verifyCertificateChain(
                $certificates,
                $effectiveTime
            );

        /*
         * 真正的 ES256 JWS 验签发生在这里。
         *
         * 验签失败时 JWT::decode() 会抛出异常,
         * 不会返回可继续使用的 Payload。
         */
        $payload = JWT::decode(
            $jws,
            new Key(
                $publicKeyPem,
                self::ALGORITHM
            )
        );

        return $this->objectToArray($payload);
    }

    /**
     * @return array{0:string,1:string}
     */
    private function splitJws(string $jws): array
    {
        $parts = explode('.', $jws);

        if (
            count($parts) !== 3 ||
            $parts[0] === '' ||
            $parts[1] === '' ||
            $parts[2] === ''
        ) {
            throw new RuntimeException(
                'JWS 必须包含 Header、Payload、Signature 三部分'
            );
        }

        return [$parts[0], $parts[1]];
    }

    /**
     * @return array<string, mixed>
     */
    private function decodeJsonPart(string $encoded): array
    {
        $json = $this->base64UrlDecode($encoded);

        try {
            $data = json_decode(
                $json,
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (Throwable $e) {
            throw new RuntimeException(
                'JWS JSON 解析失败',
                0,
                $e
            );
        }

        if (!is_array($data)) {
            throw new RuntimeException(
                'JWS 内容不是有效 JSON 对象'
            );
        }

        return $data;
    }

    private function base64UrlDecode(string $data): string
    {
        $remainder = strlen($data) % 4;

        if ($remainder !== 0) {
            $data .= str_repeat(
                '=',
                4 - $remainder
            );
        }

        $decoded = base64_decode(
            strtr($data, '-_', '+/'),
            true
        );

        if ($decoded === false) {
            throw new RuntimeException(
                'Base64URL 解码失败'
            );
        }

        return $decoded;
    }

    private function x5cToPem(string $certificate): string
    {
        /*
         * x5c 使用普通 Base64 DER,
         * 不是 Base64URL。
         */
        if (base64_decode($certificate, true) === false) {
            throw new RuntimeException(
                'x5c 证书 Base64 数据无效'
            );
        }

        return
            "-----BEGIN CERTIFICATE-----\n" .
            chunk_split(
                $certificate,
                64,
                "\n"
            ) .
            "-----END CERTIFICATE-----\n";
    }

    /**
     * @param string[] $certificates
     */
    private function verifyCertificateChain(
        array $certificates,
        int $effectiveTime
    ): string {
        [$leaf, $intermediate, $root] =
            $certificates;

        $this->assertCertificateTime(
            $leaf,
            $effectiveTime
        );

        $this->assertCertificateTime(
            $intermediate,
            $effectiveTime
        );

        $this->assertCertificateTime(
            $root,
            $effectiveTime
        );

        if (!$this->isTrustedRoot($root)) {
            throw new RuntimeException(
                'x5c 根证书不在本地 Apple 信任列表中'
            );
        }

        if (
            !$this->hasExtension(
                $leaf,
                self::LEAF_EXTENSION_OID
            )
        ) {
            throw new RuntimeException(
                'Apple JWS Leaf 证书扩展不符合要求'
            );
        }

        if (
            !$this->hasExtension(
                $intermediate,
                self::INTERMEDIATE_EXTENSION_OID
            )
        ) {
            throw new RuntimeException(
                'Apple Intermediate 证书扩展不符合要求'
            );
        }

        if (!$this->isCaCertificate($intermediate)) {
            throw new RuntimeException(
                'Intermediate 证书不是有效 CA'
            );
        }

        $intermediateKey =
            openssl_pkey_get_public($intermediate);

        if ($intermediateKey === false) {
            throw new RuntimeException(
                '无法取得 Intermediate 公钥'
            );
        }

        /*
         * Leaf 必须由 Intermediate 签发。
         */
        if (
            openssl_x509_verify(
                $leaf,
                $intermediateKey
            ) !== 1
        ) {
            throw new RuntimeException(
                'Leaf 证书签名验证失败'
            );
        }

        $rootKey =
            openssl_pkey_get_public($root);

        if ($rootKey === false) {
            throw new RuntimeException(
                '无法取得 Root 公钥'
            );
        }

        /*
         * Intermediate 必须由 Root 签发。
         */
        if (
            openssl_x509_verify(
                $intermediate,
                $rootKey
            ) !== 1
        ) {
            throw new RuntimeException(
                'Intermediate 证书签名验证失败'
            );
        }

        $leafKey =
            openssl_pkey_get_public($leaf);

        if ($leafKey === false) {
            throw new RuntimeException(
                '无法取得 Leaf 公钥'
            );
        }

        $keyDetails =
            openssl_pkey_get_details($leafKey);

        if (
            $keyDetails === false ||
            !isset($keyDetails['key'])
        ) {
            throw new RuntimeException(
                '无法导出 Leaf 公钥'
            );
        }

        return $keyDetails['key'];
    }

    private function isTrustedRoot(
        string $rootCertificate
    ): bool {
        $rootFingerprint =
            openssl_x509_fingerprint(
                $rootCertificate,
                'sha256'
            );

        if ($rootFingerprint === false) {
            throw new RuntimeException(
                '无法计算 x5c Root 指纹'
            );
        }

        foreach ($this->trustedRootFiles as $file) {
            $trustedPem =
                @file_get_contents($file);

            if ($trustedPem === false) {
                throw new RuntimeException(
                    '无法读取 Apple 根证书:' . $file
                );
            }

            $trustedFingerprint =
                openssl_x509_fingerprint(
                    $trustedPem,
                    'sha256'
                );

            if (
                $trustedFingerprint !== false &&
                hash_equals(
                    strtolower($trustedFingerprint),
                    strtolower($rootFingerprint)
                )
            ) {
                return true;
            }
        }

        return false;
    }

    private function assertCertificateTime(
        string $certificate,
        int $effectiveTime
    ): void {
        $info =
            openssl_x509_parse(
                $certificate,
                false
            );

        if ($info === false) {
            throw new RuntimeException(
                'X.509 证书解析失败'
            );
        }

        $validFrom =
            $info['validFrom_time_t'] ?? null;

        $validTo =
            $info['validTo_time_t'] ?? null;

        if (
            !is_int($validFrom) ||
            !is_int($validTo)
        ) {
            throw new RuntimeException(
                '证书缺少有效期'
            );
        }

        /*
         * 允许少量时间偏差。
         */
        $clockSkew = 300;

        if (
            $effectiveTime + $clockSkew < $validFrom ||
            $effectiveTime - $clockSkew > $validTo
        ) {
            throw new RuntimeException(
                '证书在 JWS 签名时间点无效'
            );
        }
    }

    private function hasExtension(
        string $certificate,
        string $oid
    ): bool {
        $info =
            openssl_x509_parse(
                $certificate,
                false
            );

        if ($info === false) {
            return false;
        }

        $extensions =
            $info['extensions'] ?? [];

        return is_array($extensions) &&
            array_key_exists(
                $oid,
                $extensions
            );
    }

    private function isCaCertificate(
        string $certificate
    ): bool {
        $info =
            openssl_x509_parse(
                $certificate,
                false
            );

        if ($info === false) {
            return false;
        }

        $constraints =
            $info['extensions']['basicConstraints']
            ?? '';

        return is_string($constraints) &&
            stripos(
                $constraints,
                'CA:TRUE'
            ) !== false;
    }

    /**
     * 使用 signedDate 作为离线证书有效期检查时间点。
     *
     * @param array<string,mixed> $payload
     */
    private function getEffectiveTime(
        array $payload
    ): int {
        $signedDate =
            $payload['signedDate'] ?? null;

        if (!is_numeric($signedDate)) {
            return time();
        }

        $time = (int) floor(
            ((int) $signedDate) / 1000
        );

        /*
         * signedDate 不能明显位于服务器未来时间。
         */
        if ($time > time() + 300) {
            throw new RuntimeException(
                'JWS signedDate 位于未来'
            );
        }

        return $time;
    }

    /**
     * @return array<string,mixed>
     */
    private function objectToArray(object $object): array
    {
        try {
            return json_decode(
                json_encode(
                    $object,
                    JSON_THROW_ON_ERROR
                ),
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (Throwable $e) {
            throw new RuntimeException(
                'Payload 数据转换失败',
                0,
                $e
            );
        }
    }
}

这里为什么先“偷看” Payload?

代码在真正验签以前读取了一次 Payload,是为了取得 signedDate,用它决定证书有效期检查的时间点。这一点需要特别区分:此时读出来的数据仍然是不可信的。

只有后面的证书链通过检查,并且 JWT::decode() 成功验证 ES256 Signature 后,返回的 Payload 才可以进入业务逻辑。

Apple 官方服务器库也采用类似思路:不开启在线吊销检查时,会使用签名数据里的 signedDate 作为证书验证的有效时间;开启在线检查时则使用当前时间并进一步检查证书吊销状态。

安全边界: 上面的纯 PHP 示例完成的是本地信任根、证书链签名、证书有效期、Apple 扩展和 JWS 签名验证,但没有实现 OCSP 在线证书吊销查询。如果业务对支付验证安全要求很高,应进一步实现 PKIX/OCSP 检查,或者把 JWS 验证放到采用 Apple 官方 App Store Server Library 的 Java、Python、Node.js 或 Swift 服务中。Apple 当前官方服务器库列出的语言并不包含 PHP。

五、编写苹果 V2 通知接收入口

有了验证器后,再建立实际接收通知的 public/apple-notify.php。

这里假设你的 Bundle ID 是 com.example.myapp,生产环境 App Apple ID 是 1234567890。实际项目必须换成 App Store Connect 中自己的值。

<?php

declare(strict_types=1);

use App\Security\AppleJwsVerifier;

require dirname(__DIR__) . '/vendor/autoload.php';

header('Content-Type: application/json; charset=utf-8');

$expectedBundleId = 'com.example.myapp';
$expectedEnvironment = 'Production';
$expectedAppAppleId = 1234567890;

$verifier = new AppleJwsVerifier([
    dirname(__DIR__) . '/certs/apple-root-ca.pem',
]);

try {
    $rawBody = file_get_contents('php://input');

    if (
        $rawBody === false ||
        trim($rawBody) === ''
    ) {
        throw new RuntimeException(
            '请求体为空'
        );
    }

    $request = json_decode(
        $rawBody,
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    if (
        !is_array($request) ||
        !isset($request['signedPayload']) ||
        !is_string($request['signedPayload'])
    ) {
        throw new RuntimeException(
            '缺少 signedPayload'
        );
    }

    /*
     * 第一层:验证整个 App Store V2 Notification。
     */
    $notification =
        $verifier->verify(
            $request['signedPayload']
        );

    $notificationType =
        $notification['notificationType']
        ?? null;

    $notificationUuid =
        $notification['notificationUUID']
        ?? null;

    $signedDate =
        $notification['signedDate']
        ?? null;

    /*
     * TEST 通知用于检查服务器是否能正常接收 V2。
     * 没有实际订单业务时可以直接记录并返回成功。
     */
    if ($notificationType === 'TEST') {
        http_response_code(204);
        exit;
    }

    /*
     * 本文处理常规 In-App Purchase 的 data 分支。
     */
    $data = $notification['data'] ?? null;

    if (!is_array($data)) {
        throw new RuntimeException(
            '当前通知没有 data,需按通知类型处理其他 V2 Payload 分支'
        );
    }

    /*
     * JWS 签名有效,只能证明内容由 Apple 签名。
     *
     * 接下来还必须确认这份数据属于自己的 App。
     */
    if (
        ($data['bundleId'] ?? null)
        !== $expectedBundleId
    ) {
        throw new RuntimeException(
            'bundleId 不匹配'
        );
    }

    if (
        ($data['environment'] ?? null)
        !== $expectedEnvironment
    ) {
        throw new RuntimeException(
            'environment 不匹配'
        );
    }

    /*
     * Sandbox 中 appAppleId 可能不存在,
     * Production 建议固定检查。
     */
    if (
        $expectedEnvironment === 'Production' &&
        (int) ($data['appAppleId'] ?? 0)
        !== $expectedAppAppleId
    ) {
        throw new RuntimeException(
            'appAppleId 不匹配'
        );
    }

    $transaction = null;
    $renewal = null;

    /*
     * 第二层:验证交易 JWS。
     */
    if (
        isset($data['signedTransactionInfo']) &&
        is_string(
            $data['signedTransactionInfo']
        )
    ) {
        $transaction =
            $verifier->verify(
                $data['signedTransactionInfo']
            );

        if (
            ($transaction['bundleId'] ?? null)
            !== $expectedBundleId
        ) {
            throw new RuntimeException(
                '交易 bundleId 不匹配'
            );
        }

        if (
            ($transaction['environment'] ?? null)
            !== $expectedEnvironment
        ) {
            throw new RuntimeException(
                '交易 environment 不匹配'
            );
        }
    }

    /*
     * 第三层:如果存在订阅续订信息,也必须单独验签。
     */
    if (
        isset($data['signedRenewalInfo']) &&
        is_string(
            $data['signedRenewalInfo']
        )
    ) {
        $renewal =
            $verifier->verify(
                $data['signedRenewalInfo']
            );

        if (
            ($renewal['environment'] ?? null)
            !== $expectedEnvironment
        ) {
            throw new RuntimeException(
                '续订 environment 不匹配'
            );
        }
    }

    /*
     * 到这里以后,notification、transaction、
     * renewal 才是已经验证的数据。
     *
     * 下面应进入数据库事务或可靠消息队列。
     */

    $transactionId =
        $transaction['transactionId']
        ?? null;

    $originalTransactionId =
        $transaction['originalTransactionId']
        ?? null;

    $productId =
        $transaction['productId']
        ?? null;

    $expiresDate =
        $transaction['expiresDate']
        ?? null;

    /*
     * 示例:
     *
     * 1. 用 notificationUUID 做幂等检查;
     * 2. 保存原始已验证通知;
     * 3. 根据 notificationType 更新订单/订阅;
     * 4. 数据库提交成功以后再返回 2xx。
     */

    http_response_code(204);

} catch (JsonException $e) {

    error_log(
        'Apple notification JSON error: ' .
        $e->getMessage()
    );

    http_response_code(400);

} catch (Throwable $e) {

    error_log(
        'Apple notification verify error: ' .
        $e->getMessage()
    );

    /*
     * 如果属于数据库、Redis、队列等暂时性故障,
     * 实际项目应返回 5xx,让 Apple 后续重试。
     *
     * 对明确无效的请求,则可根据自己的接口策略
     * 返回适当的 4xx。
     */
    http_response_code(400);
}

六、为什么 signedTransactionInfo 还要再验证一次

这是实现 V2 通知时很容易漏掉的一步。

外层 signedPayload 验签后,会得到类似这样的结构关系:

notification
└── data
    ├── bundleId
    ├── environment
    ├── signedTransactionInfo
    └── signedRenewalInfo

其中 signedTransactionInfo 不是普通 JSON 对象,而是另一条完整 JWS。它包含交易相关信息,例如:

  • transactionId:当前交易唯一标识;
  • originalTransactionId:原始购买交易标识,可用于关联订阅生命周期;
  • productId:商品 ID;
  • purchaseDate:购买时间;
  • expiresDate:订阅交易的到期或续订时间;
  • revocationDate:退款或撤销发生时间;
  • environment:Sandbox 或 Production;
  • bundleId:所属应用 Bundle ID。

因此处理续费、退款、撤销这类业务时,真正与订单对应的数据往往来自这层 JWS,而不是只看外层 notificationType。

七、signedRenewalInfo 主要看什么

自动续期订阅通知中还可能包含 signedRenewalInfo。它同样是 Apple 签名的 JWS,因此可以继续复用同一个 AppleJwsVerifier。

解析后常用字段包括:

字段 作用
originalTransactionId 关联这一组自动续订订阅。
productId 当前订阅商品。
autoRenewProductId 下一次预计续订的商品。
autoRenewStatus 表示自动续订当前是开启还是关闭。
renewalDate 最近一次自动续订购买的到期时间;对已过期订阅也可能存在。
expirationIntent 订阅到期时,可用于了解取消、账单错误等到期原因。
isInBillingRetryPeriod 表示 Apple 是否仍在尝试解决账单问题并自动续费。
autoRenewStatus = 0 不等于“用户现在已经没有会员权限”。它只说明自动续订已经关闭。当前订阅周期是否仍有效,还应结合交易的 expiresDate、撤销信息以及通知类型判断。

八、不要漏掉 bundleId、environment 和 appAppleId

很多实现做到 JWS 签名正确就停止了,但支付接口还有一个重要问题:这份有效的 Apple 数据是不是属于你的应用?

所以外层通知验签后,至少还应检查:

$data['bundleId'] === 'com.example.myapp'

$data['environment'] === 'Production'

生产环境还建议检查 appAppleId。Apple 官方服务器库在 Production 模式下同样要求提供 App Apple ID,并验证应用身份。

注意 Environment 字段的正式值是:

Sandbox
Production

TestFlight 产生的相关 App Store 服务端通知同样使用 Sandbox 环境,因此测试时不要把 TestFlight 当成 Production 数据处理。

九、使用 notificationUUID 防止重复处理

苹果通知不能按“一次 HTTP 请求一定只来一次”设计。网络超时、你的服务器返回非成功状态或其他情况,都可能造成通知再次发送。

V2 Payload 中的 notificationUUID 就可以用于识别重复通知。比较稳妥的做法是在数据库中给它建立唯一约束。

例如 MySQL:

CREATE TABLE app_store_notification_log (
    notification_uuid VARCHAR(64) NOT NULL,
    notification_type VARCHAR(64) NOT NULL,
    signed_date BIGINT NOT NULL,
    transaction_id VARCHAR(64) NULL,
    original_transaction_id VARCHAR(64) NULL,
    payload_json JSON NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (notification_uuid)
);

接到通知后可以按照这样的顺序处理:

  1. 验证外层 JWS;
  2. 检查 notificationUUID 是否已经处理;
  3. 验证应用身份;
  4. 验证交易和续订 JWS;
  5. 在数据库事务中写入通知日志并更新业务状态;
  6. 提交成功以后再返回 HTTP 成功状态。

如果服务器还没有真正完成订单状态更新,就不要为了“让苹果停止重试”而过早返回成功。

Apple 文档规定,成功处理通知可以返回 HTTP 200–206;未成功时可以通过非成功状态让通知进入重试流程。

十、signedDate 还可以解决通知先后顺序问题

支付通知不仅可能重复,也可能因为网络、重试和异步处理导致服务器接收到的顺序与业务发生顺序不同。

V2 Payload 的 signedDate 是 Apple 对数据执行签名时的 Unix 毫秒时间戳。如果同一笔业务状态收到多个通知,可以在业务表中保存最近一次已经处理的 signedDate,避免旧状态覆盖新状态。

例如:

$newSignedDate =
    (int) $notification['signedDate'];

if ($newSignedDate < $lastProcessedSignedDate) {
    // 这是比当前状态更旧的通知。
    // 可以保留日志,但不要让旧状态覆盖新状态。
}

不过 signedDate 与 notificationUUID 解决的是两个不同问题:前者用于判断状态时间顺序,后者用于识别同一通知是否重复。实际项目通常两个都保存。

十一、常见的错误写法

1. 只 Base64 解码 Payload

下面这样的代码只能读数据,不能验证数据:

$parts = explode('.', $signedPayload);

$payload = json_decode(
    base64_decode($parts[1]),
    true
);

而且普通 base64_decode() 还没有正确处理 Base64URL 的 - 和 _。更重要的是,即使成功解码,也完全没有检查 Signature。

2. 从 x5c[0] 取得公钥后直接 JWT::decode()

这种做法比单纯 Base64 解码更像“验签”,但仍存在信任问题:

攻击者私钥
    ↓
攻击者自己生成 Leaf 证书
    ↓
攻击者自己生成 x5c
    ↓
攻击者自己签一个 JWS
    ↓
你的程序直接信任 x5c[0]
    ↓
签名当然验证成功

所以一定要追溯到服务器预先保存的 Apple Root CA。

3. 只验证外层 signedPayload

外层验签完成以后,signedTransactionInfo 和 signedRenewalInfo 仍然应该作为独立 JWS 再走一次同样的验证流程。Apple 官方文档明确说明这些签名数据都可以并且应当在服务器端验证。

4. 没有区分 Sandbox 与 Production

如果测试通知意外进入正式业务数据库,而代码只根据 productId 发放权益,就可能造成测试订单影响正式账户。至少要固定检查 environment。

5. 把 originalTransactionId 当作每次续费交易 ID

对于自动续订订阅,originalTransactionId 更适合用来关联整个订阅关系,而每一次续费会有自己的 transactionId。数据库设计时最好分别保存这两个字段。

十二、返回 400 还是 500 要区分问题性质

前面的接收示例为了让验证流程容易阅读,把异常统一简化成了 400。正式项目最好再区分错误。

问题 建议处理
请求没有 signedPayload 视为请求格式错误。
JWS 格式错误或签名不合法 记录安全日志,不进入订单逻辑。
bundleId 或 environment 不匹配 拒绝处理,并记录异常来源。
MySQL 暂时不可用 返回 5xx,使通知后续能够重试。
消息队列暂时不可用 如果通知尚未可靠落库,同样不要返回成功。
业务已经成功持久化 返回 2xx。

十三、生产环境还需要补充的安全措施

如果只是开发环境验证流程,前面的代码已经把最容易漏掉的“x5c 信任链 + ES256 + 内层 JWS”串起来了。真正用于支付生产环境时,还建议补上下面几个部分。

证书吊销检查

本例没有自行实现 OCSP。证书即使在有效期内,也存在提前被吊销的可能。Apple 官方服务器库提供在线证书检查机制,因此高安全要求系统不应该把“有效期没过”理解为完整的证书状态验证。

根证书更新机制

不要把当前根证书的具体内容永久写死在程序代码里。应把信任证书作为独立部署资源管理,并在 Apple PKI 更新时通过发布流程更新。

原始请求留档

可以保存原始 signedPayload 或已经验证后的 Payload,方便支付问题排查。但日志系统应设置合理访问权限,不要把完整支付通知写到公开错误页面。

数据库操作保持幂等

发放会员、增加虚拟商品、撤回权限这些操作,不能简单写成“收到一次就执行一次”。通知日志表、订单表和权益更新最好放进一个明确的幂等处理流程。

十四、最终应该得到怎样的处理结构

PHP使用firebase/PHP-jwt解析苹果支付V2 JWS通知
图 4:PHP 处理苹果支付 V2 JWS 时需要同时满足密码验证、应用身份和业务幂等三个层面的要求。

把整个过程压缩成代码结构,大致应该是:

POST
 ↓
读取 signedPayload
 ↓
解析 JWS Header
 ↓
检查 alg = ES256
 ↓
读取 x5c
 ↓
本地 Apple Root CA
 ↓
验证 Root → Intermediate → Leaf
 ↓
取得 Leaf Public Key
 ↓
JWT::decode() 验证 signedPayload
 ↓
检查 bundleId / environment / appAppleId
 ↓
验证 signedTransactionInfo
 ↓
验证 signedRenewalInfo
 ↓
notificationUUID 幂等
 ↓
根据 notificationType 更新业务
 ↓
可靠保存成功
 ↓
返回 HTTP 2xx

总结

PHP 使用 firebase/php-jwt 解析苹果支付 V2 JWS 的关键,并不是如何把 signedPayload 转成数组,而是如何保证转出来的数据确实可以信任。

一套完整的实现至少需要完成三层验证:第一层是利用本地 Apple Root CA 验证 JWS Header 中的 x5c 证书链,再使用叶子证书公钥验证外层 signedPayload 的 ES256 签名;第二层是检查 bundleId、environment 和生产环境的 appAppleId,确认通知属于自己的应用;第三层则是继续验证 signedTransactionInfo 和 signedRenewalInfo 这些内层 JWS。

完成验签之后,业务层还要用 notificationUUID 防止重复执行,并利用 signedDate 控制通知状态的先后顺序。这样处理以后,V2 通知才真正能够安全地用于自动续订、退款、撤销以及其他 App Store 内购状态更新,而不是停留在“能够把 JWS 解码出来”的阶段。

最需要记住的一点: 不要直接信任从 x5c[0] 取得的公钥。只有这张 Leaf 证书已经通过 Intermediate 追溯到服务器本地保存的 Apple Root CA,随后 ES256 JWS 验签也成功,Payload 才具备进入支付业务逻辑的前提。
相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。