代码 App Store Server Notifications V2 验签:三层 JWS 只验外层,webhook 就是公开的订阅开关

2026-10-01 09:01:00

App Store Server Notifications V2 验签:三层 JWS 只验外层,webhook 就是公开的订阅开关

面向要自己落地权益的后端团队:处理苹果订阅续费、退款、掉单,需要接收 App Store Server Notifications V2(下称 ASSN V2)。

不适用的情况先说清楚:只用客户端 StoreKit 2 做本地校验、权益不落服务端的;已经用 RevenueCat 这类托管方案、自己的系统不持有权益状态的。这两类接入 ASSN V2 主要是重复劳动。

一、通知长什么样

苹果往你的端点 POST 一个 JSON body,里面只有一个字段 signedPayload,值是一个 JWS(三段式 header.payload.signature,每段 base64url)。

对 payload 段做 base64url 解码,得到 responseBodyV2DecodedPayload,字段有:

  • notificationType
  • subtype(可选)
  • notificationUUID
  • version(值为 "2.0")
  • signedDate(毫秒 UNIX 时间戳)
  • data

data 里包含 appAppleId、bundleId、bundleVersion、environment、signedTransactionInfo(又是一个 JWS)、signedRenewalInfo(又是一个 JWS)。

payload 顶层有四个互斥字段,一次只出现一个:data / appData / summary(仅 RENEWAL_EXTENSION 的 SUMMARY 子类型)/ externalPurchaseToken。写解析代码时别假设 data 一定存在。

二、为什么必须验签

不验签的后果很直接:任何人往你的 webhook POST 一个伪造的 signedPayload,就能给自己开订阅。只做 base64 解码就信任内容,是最常见的错。

三层 JWS 各自独立签名:外层 signedPayload、内层 signedTransactionInfo、内层 signedRenewalInfo,三个都要验。只验外层、放过内层,等于留了口子——攻击者可以拿一条真实的合法外层通知,替换掉里面的交易信息。

三、验签怎么做

每个 JWS 的 header 满足:

  • alg 恒为 ES256(ECDSA P-256 + SHA-256)。拿 RS256 或 HMAC 去验必然失败。
  • x5c 是证书链数组,顺序按 Apple JWSDecodedHeader 文档:
    • [0] leaf 证书,它的公钥就是签这条 JWS 的密钥
    • [1] Apple WWDR 中间证书,带扩展 OID 1.2.840.113635.100.6.2.1
    • [2] Apple 根证书

校验顺序:先校验 leaf 的证书用途(Mac App Store Receipt Signing 对应的 OID),再用 leaf 公钥验 JWS 签名;同时验证 leaf 由 intermediate 签发、intermediate 由 root 签发;root 必须等于你从 Apple PKI 站下载并固化在服务里的根证书(例如 Apple Root CA - G3)。链上每张证书的有效期,按通知的 signedDate 判定(离线检查);如果开启在线检查(OCSP 撤销 + 按当前时间判有效期),则用当前时间。

手工验链的思路是用受信任的 root 去 verify intermediate,再 verify leaf:

# 文件名按自己导出的证书改,参数未逐项实测
openssl verify -CAfile AppleRootCA-G3.pem intermediate.pem
openssl verify -CAfile AppleRootCA-G3.pem -untrusted intermediate.pem leaf.pem

官方提供 App Store Server Library,覆盖 Node / Swift / Python / Java 四语言,核心类是 SignedDataVerifier,方法为 verifyAndDecodeNotification / verifyAndDecodeTransaction / verifyAndDecodeRenewalInfo。库内部会自己走链、查 OID、缓存已验证公钥。手写实现容易漏两点:证书用途 OID 检查,以及 x5c 长度检查——库要求 x5c 恰好 3 张,不满足直接报 INVALID_CHAIN_LENGTH。

四、验签之外的两个必查字段

这两项漏掉,签名验得再对也没用:

  • bundleId 必须等于你自己的 App,不匹配时库抛 INVALID_APP_IDENTIFIER。
  • environment 必须匹配(Sandbox vs Production),不匹配抛 INVALID_ENVIRONMENT。生产环境还要比对 appAppleId。

沙箱和 TestFlight 的通知会打到同一个端点。不校验 environment,沙箱订阅就会被当成生产环境发货。

五、幂等与重发

notificationUUID 是每条通知的唯一标识,用它去重。苹果对非 2xx 响应的端点会重发,间隔递增,常见说法是 1h / 12h / 24h / 48h / 72h(具体节流曲线未实测,以官方文档为准)。重发意味着同一业务事件会来多条,不做去重就会重复发货或重复退款。

应答必须快速返回 2xx(200)。做法是先应答、再异步处理业务逻辑;把处理逻辑放在应答之前,一旦超时就会被判为失败并触发重发。

六、notificationType / subtype 该干什么

  • SUBSCRIBED:子类型 INITIAL_BUY(首购)/ RESUBSCRIBE(过期后重订)→ 开权益。
  • DID_RENEW:子类型 BILLING_RECOVERY(宽限期后恢复扣款成功)→ 开或延权益。
  • DID_CHANGE_RENEWAL_STATUS:AUTO_RENEW_DISABLED(用户关自动续订,权益到到期日仍然有效,打标做挽回)/ AUTO_RENEW_ENABLED(恢复自动续订,清标记)。
  • DID_FAIL_TO_RENEW:有子类型 GRACE_PERIOD(扣款失败但苹果在宽限期内重试,不要立刻收权,提示用户更新支付方式);无子类型表示不再重试 → 收权。
  • EXPIRED:子类型 VOLUNTARY(主动放弃)/ BILLING_RETRY(扣款重试耗尽)/ PRICE_INCREASE(未接受涨价)→ 收权。
  • REFUND:苹果已退款 → 立即收回权益。REFUND_DECLINED:退款被驳回。REVOKE:家庭共享等场景被撤销。CONSUMPTION_REQUEST:消耗型道具退款前,苹果来问你要消费数据。

关键点:权益归属以 transactionId / originalTransactionId / productId / expiresDate 为准,这些字段在内层 signedTransactionInfo 里。不要仅凭 notificationType 就发货。

七、兜底:通知会丢,用 Server API 对账

通知可能丢失,用 App Store Server API 兜底:

  • Get All Subscription Statuses:按 originalTransactionId 拿当前订阅状态。
  • Get Transaction Info / Get Transaction History:用于对账。

调 API 需要用 App Store Connect 生成的 API Key 签 JWT 做鉴权,key 要安全保存,不要写进代码仓库。

参考链接

推荐文章

程序员茄子在线接单