PHP使用firebase/PHP-jwt解析苹果支付V2 JWS通知
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+ 的实现。
一、先弄清 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
本文代码采用 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 源码里。
四、编写 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 作为证书验证的有效时间;开启在线检查时则使用当前时间并进一步检查证书吊销状态。
五、编写苹果 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)
);
接到通知后可以按照这样的顺序处理:
- 验证外层 JWS;
- 检查
notificationUUID是否已经处理; - 验证应用身份;
- 验证交易和续订 JWS;
- 在数据库事务中写入通知日志并更新业务状态;
- 提交成功以后再返回 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,方便支付问题排查。但日志系统应设置合理访问权限,不要把完整支付通知写到公开错误页面。
数据库操作保持幂等
发放会员、增加虚拟商品、撤回权限这些操作,不能简单写成“收到一次就执行一次”。通知日志表、订单表和权益更新最好放进一个明确的幂等处理流程。
十四、最终应该得到怎样的处理结构
把整个过程压缩成代码结构,大致应该是:
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 才具备进入支付业务逻辑的前提。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









