通过 Apple 登录会发出四种通知,而不是三种
Apple 那则关于“通过 Apple 登录”服务器到服务器通知的开发者公告,列出了您的接口会收到的三件事:邮件转发偏好的变更、用户在您应用内删除账户,以及 Apple Account 被永久删除。3 而 API 文档定义的是四种彼此独立的事件类型。1
照着公告写出来的实现只会处理三个分支,并且悄无声息地丢掉第四种事件。 公告把 email-enabled 和 email-disabled 压缩成了关于转发偏好的一条要点。实际上它们是两条独立的通知,携带各自不同的 type 值;如果代码只按 type 分支而没有兜底分支,作者漏掉的那一个就会被直接忽略。
四种事件里还有两种的含义超出了字面,其中一种会改变您应用的认证状态。
摘要
“通过 Apple 登录”会投递四种服务器到服务器通知类型:email-enabled、email-disabled、consent-revoked 和 account-deleted。1 负载以 JWS 的形式,包裹在一个 JSON 对象的 payload 键下送达,由 Apple 的私钥签名;在据此采取任何动作之前,必须用头部 alg 参数所指定的算法完成验签。1 consent-revoked 会使用户凭据失效,因此它是一个认证事件,而非偏好变更。原生应用在 Apple Account 被删除时收不到任何客户端回调,服务器通知是唯一的信号。1 至于重试与投递语义,Apple 没有任何说明。
四种事件类型
每条通知都在 events 声明中携带一个 type 值。1
type |
含义 |
|---|---|
email-enabled |
用户通过“隐藏我的邮件地址”开启了向个人邮箱的邮件转发 |
email-disabled |
用户关闭了邮件转发 |
consent-revoked |
用户撤回了对您应用的授权,其凭据已失效 |
account-deleted |
用户请求永久删除自己的 Apple Account |
被公告合并掉的正是这两种邮件事件。它们各自都有独立价值:email-disabled 意味着您发往中继地址的邮件不再能送达用户,email-enabled 意味着又能送达了。把它们当成同一个“偏好已变更”事件来处理,等于逼着自己再回头去查当前状态是什么——而通知本来已经告诉您了。
两种名不副实的事件
consent-revoked 是认证事件。 Apple 的原文描述是:用户“撤回了对您应用使用其 Apple Account 的授权,其凭据随之失效”。1 不是标记为弃用,也不是即将过期。是失效。
如果某个应用把它和邮件事件一同记录下来,只更新一行偏好数据,那么界面上仍会呈现已登录状态,背后却是一套无法再完成认证的凭据。用户会一直看到自己的账户,直到下一次令牌刷新失败,然后看到更糟的画面。正确做法是结束会话并跳转到重新认证,与处理被撤销的 OAuth 授权走同一条代码路径。
account-deleted 可能是您能收到的唯一通知。 Apple 明确说明:当用户永久删除自己的 Apple Account 时,“通过 Apple 登录”会使所有用户令牌失效,并为所有关联应用停用邮件转发;而对于原生应用,系统不会发送客户端回调。1
这句话是“到底值不值得跑一个接口”这个问题最有力的答案。一支只做 iOS、没有服务器到服务器接口的团队,根本没有任何途径得知账户已经不在了。记录仍然留着,中继地址停止工作,而您本应履行的删除义务无从谈起——因为没有任何东西告诉过您有东西需要删。
注册接口
配置在 Certificates, Identifiers & Profiles 中完成:选择 Identifiers,选中您的 App ID,启用“通过 Apple 登录”服务,点击 Configure,填入接口 URL。2
在围绕它做设计之前,几条约束值得先读一遍。2
- 每个“通过 Apple 登录”应用分组与密钥只能配一个 URL。 不是每个应用一个。
- 只能在主 App ID 上注册。
- URL 必须是包含协议、主机和路径的绝对 URI:
https://example.com/path/to/endpoint - 接收通知要求 TLS 1.2 或更高版本。
Apple 的 API 文档另外补充说,同一个 URL 可以用于多个开发者团队和多个应用。1 与“每个分组一个 URL”的规则合起来读,合理的理解是:可以由单个服务接收全部通知,而各个分组各自注册指向它的地址。Apple 并没有把两者的关系讲清楚,因此把共享接口视作“可行”而非“官方背书”更稳妥,并且在依赖它之前,务必确保处理程序能判断出一条通知属于哪个应用。
TLS 1.2 这条下限连着一个更大的趋势。OS 27 开始对管理流量强制执行更严格的 TLS 要求,同样以 1.2 为下限,另加符合 ATS 的加密套件和证书。今天满足 Apple 这项要求的接口,并不自动符合 ATS,而整体走向是越收越紧,不会放松。
解析负载
投递以 HTTP POST 形式到达,请求体是一个 JSON 对象,签名令牌藏在里面:1
{
"payload": "<SERVER_TO_SERVER_NOTIFICATION_JWS>"
}
JWS 是被包裹的,不是裸的。 先解析 JSON,取出 payload,然后再验签。如果实现把整个请求体直接丢给 JWS 验证器,第一条通知就会失败,而且报错表现为令牌格式错误,而不是包裹方式弄错了——这会把人引向错误的排查方向。
验签先于解读。负载由 Apple 的私钥以 JSON Web Signature 格式签名,Apple 给出的指示是:检查 JWS,使用头部 alg 参数指定的算法验证签名。1 只有签名通过之后,才去读 events 声明并按 type 分支。
从通用的 JWS 实践里,有两个习惯值得保留:绝不信任任何可能让调用方降级验证的 alg 值;确认令牌的签发方与受众与预期一致,而不是照单全收任何格式正确、由 Apple 签名的令牌。
解码后的结构
验签通过后,一条解码后的 consent-revoked 通知长这样:1
{
"iss": "https://appleid.apple.com",
"aud": "com.mytest.app",
"iat": 1508184845,
"jti": "abede...67890",
"events": {
"type": "consent-revoked",
"sub": "820417.faa325acbc78e1be1668ba852d492d8a.0219",
"event_time": 1508184845
}
}
account-deleted 字段相同,只是 type 不同。邮件类事件会多出两个字段:email 和 is_private_email。
这个结构里有三个细节,如果事先不知道、在线上撞见,会白白耗掉您不少时间。
events 是对象,不是数组。 名字是复数,值却是单个事件。照着名字而不是照着结构写的代码,会去遍历一个字典,然后拿到一堆键名。
is_private_email 是字符串。 Apple 的示例里写的是带引号的 "true",而不是 JSON 的布尔值 true。严格的解码器把它映射成 Bool 会直接失败;宽松的解码器若把任何非空字符串都当作真,则会以错误的理由得到正确的答案,然后在 "false" 上翻车。
sub 是稳定的用户标识符,与登录时拿到的是同一个值,也是您据以定位这条通知所指账户的依据。aud 是您的客户端标识符,正是它让共享接口得以为多个应用分发通知。
关于 Apple 自家示例的一点提醒:两个邮件类负载在 "is_private_email": "true" 和 "event_time" 之间漏了一个逗号。把任一段复制进 JSON 解析器,文档都会被判为非法。结构没问题,标点有问题——把它粘进测试夹具的人,会为一个不属于自己的语法错误浪费十分钟。
Apple 的术语也有些飘忽。正文说负载是 JSON Web Signature 格式,包裹层的示例却把值命名为 SERVER_TO_SERVER_NOTIFICATION_JWT。1 指的是同一个东西:签名后的 JWT 就是一个负载为 JSON 的 JWS。查阅他们的文档时两个词都会碰到,知道这一点省心。
一个完整的处理程序
正确处理程序的形态,由上述约束自然推导而来。以下用 Python 配合 PyJWT:
import json
import jwt
from jwt import PyJWKClient
# Apple publishes its signing keys as a JWKS. Cache the client;
# it fetches and caches keys rather than hitting Apple per request.
JWKS = PyJWKClient("https://appleid.apple.com/auth/keys")
CLIENT_ID = "com.mytest.app" # your aud value
def handle_notification(request_body: bytes):
# 1. The JWS is wrapped in JSON under "payload", not the raw body.
wrapper = json.loads(request_body)
token = wrapper["payload"]
# 2. Resolve the signing key by the token's kid, then verify.
# Pin the algorithm. Never read alg from the token to decide.
signing_key = JWKS.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=CLIENT_ID,
issuer="https://appleid.apple.com",
)
# 3. Only now is anything trustworthy.
event = claims["events"] # an object, not a list
apple_user_id = event["sub"] # stable identifier from sign-in
match event["type"]:
case "consent-revoked" | "account-deleted":
end_all_sessions(apple_user_id)
mark_account_unlinked(apple_user_id)
case "email-disabled":
set_email_forwarding(apple_user_id, enabled=False)
case "email-enabled":
set_email_forwarding(apple_user_id, enabled=True)
case other:
log_unknown_event(other) # do not fail silently
其中有几行是承重的。
固定算法。 Apple 当前线上的 JWKS 发布了三把 RSA 密钥,全部是 RS256,use: sig,kid 各不相同。传入 algorithms=["RS256"] 而不是从令牌里读 alg,堵死了那条经典的降级路径——攻击者送来一个声称 alg: none 的令牌。
按 kid 取密钥,不要拿第一把。 三把密钥同时在线,这正是密钥轮换从外部看到的样子。抓 keys[0] 的处理程序在 Apple 轮换之前一直好用,轮换之后就会对一部分令牌失败——这种问题调试起来相当折磨人。
校验 aud 和 iss。 少了这两项,您就会接受任何签名有效的 Apple 令牌,包括为另一个应用签发的那种。
保留兜底分支。 否则第五种事件类型出现时会凭空消失。今天照着 Apple 那份三项公告写出来的实现,丢掉 email-enabled 或 email-disabled,走的正是这条路。
Apple 没有写进文档的部分
API 文档和账户帮助页面,都没有提到任何投递保证。在两处分别检索重试、重投、确认、状态码、超时和幂等,一无所获。
因此截至本文写作时,以下问题在 Apple 的文档里都没有答案:
- 投递失败是否会重试,重试几次
- 若有重试,重试窗口有多长
- 您的接口应返回什么状态码来表示成功
- 同一条通知是否可能到达两次
这种缺失会带来设计上的后果——以下是我基于 Apple 所述内容的推断,而非对文档的转述。一个投递语义未经说明的接口,不能被当作权威事件流来对待。防守姿态是:把每条通知当成“有什么东西变了”的提示,与自己的记录做对账,而不是盲目套用事件。让处理程序保持幂等,因为您无法排除重复。不要构建那种“正确性依赖于收全每一条通知”的流程,因为您无法确认自己收全了。
如果您的接口宕机一小时,从文档里您无从判断:是丢失了一小时的账户删除事件,还是它们被排队在了某处。就当丢了来设计。
而且也没有可供测试的官方途径
在两个页面里检索沙箱、模拟、触发,同样一无所获。Apple 没有提供任何按需触发通知的机制。
这就留下一个尴尬的循环。最要紧的两种事件——consent-revoked 和 account-deleted——是由用户撤回对您应用的访问权或删除自己的 Apple Account 产生的。要用一条真实的 account-deleted 验证处理程序,就意味着有人得删掉一个 Apple Account。这种测试没人会做第二遍。
可行的替代方案是把问题拆开。照着上面的结构自己构造解码后的负载,用它们对分支逻辑、幂等性和对账逻辑做单元测试。另一边,用一条您真能造出来的真实通知去测传输与签名链路:为一个测试用 Apple Account 撤回授权是可恢复的,删除账户则不可恢复,而撤回授权同样能端到端地跑通包裹解析、kid 查找和签名校验。
无论如何,注册之前请先确认接口可达且能及时响应。注册了却从未验证的接口,正是某支团队几个月后才发现“上线以来的每一条通知都发去了一个证书早已过期的 URL”的原因。
一项已经生效的要求
这件事之所以出现在开发者新闻里:自 2026年1月1日起,位于韩国的开发者在注册新的 Services ID 或更新已有 Services ID 时,若要使用“通过 Apple 登录”将网站与应用关联,必须提供服务器到服务器通知接口。3 Apple 于 2025年10月9日发布了这一公告。
这项要求范围有限,而且已经生效数月,并非什么需要提前准备的事。它的价值在于方向性。Apple 已经开始在至少一个司法辖区把该接口变为强制,其理由具有普遍性:让人们掌控自己分享出去的个人数据,并让账户删除真正传导下去。这套逻辑没有一处是韩国独有的。
既然接口早晚要做,不如赶在监管机构替您定下期限之前把它做出来。
关键要点
给后端工程师:
- 处理四种类型,不是三种。email-enabled 与 email-disabled 是分别到达的。
- 先解析 JSON 请求体、取出 payload,再把任何东西交给 JWS 验证器。
- 用头部 alg 指定的算法验签,之后才读取 events 声明。
- 让处理程序幂等,并与自己的记录对账。Apple 的文档没有给出任何投递保证。
给 iOS 团队:
- consent-revoked 会使凭据失效。把它当作终结会话的认证事件,而不是偏好更新。
- Apple Account 被删除时,原生应用收不到客户端回调。没有接口,您永远不会知道这件事发生过。
给还在权衡值不值得做的人: - 对位于韩国的开发者而言,该接口自 2026年1月起已是强制要求,而其背后的理由具有普遍性。
常见问题
一共有几种通知类型?
四种:email-enabled、email-disabled、consent-revoked 和 account-deleted。1 Apple 的开发者新闻公告只描述了三种,把两个邮件事件合并成了关于转发偏好的一条要点。3
consent-revoked 对我的会话意味着什么?
用户的凭据已失效。1 请按处理被撤销的 OAuth 授权那样对待它:结束会话,把用户引导至重新认证,而不是更新一个偏好项然后继续。
如果只发布原生 iOS 应用,还需要接口吗?
Apple 明确说明,Apple Account 被永久删除时,系统不会向原生应用发送客户端回调。1 没有服务器到服务器接口,就没有任何机制会告知您。
接口应该返回什么?宕机了会怎样?
Apple 没有说明状态码预期、重试行为,也没有说明是否会出现重复投递。请按“至少一次”、甚至可能是“至多一次”的投递来设计:让处理程序幂等,与自己的记录对账,不要默认每条通知都已到达。
一个接口能服务多个应用吗?
Apple 的文档表示,同一个 URL 可用于多个开发者团队和应用。1 注册层面是:在主 App ID 上,每个“通过 Apple 登录”应用分组与密钥对应一个 URL。2 共享服务是可行的,前提是您的处理程序能判定每条通知属于哪个应用。
资料来源
-
Apple, “Processing changes for Sign in with Apple accounts.” 四种事件类型(
email-enabled、email-disabled、consent-revoked、account-deleted)、JWS 负载格式与“使用头部alg参数验签”的指示、{"payload": "<JWS>"}包裹结构、“撤回授权导致凭据失效”的表述、“原生应用在账户删除时收不到客户端回调”的说明、TLS 1.2 服务器要求,以及“同一 URL 可跨多个团队和应用使用”的许可,均出自此处。检索于 2026-08-02。 ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple, “Enabling server-to-server notifications.” 经由 Certificates, Identifiers & Profiles 的注册路径、“每个应用分组与密钥一个 URL”规则、主 App ID 限制、绝对 URI 要求以及 TLS 1.2 要求,均出自此处。检索于 2026-08-02。 ↩↩↩
-
Apple Developer News, “New requirement for apps using Sign in with Apple for account creation,” 2025年10月9日。2026年1月1日生效的韩国要求,以及“接口会收到什么”的三项摘要,均出自此处。 ↩↩↩