← 所有文章

Sign in with Apple 會送出四種通知,不是三種

Apple 針對 Sign in with Apple 伺服器對伺服器通知發布的開發者公告,列出您的端點會收到的三件事:電子郵件轉寄偏好設定的變更、使用者在您 App 內刪除帳號,以及永久刪除 Apple Account。3 但 API 的官方文件定義了四種不同的事件類型。1

照公告寫成的實作只會處理三個分支,並悄悄丟掉第四種事件。 公告把 email-enabledemail-disabled 併成同一條「轉寄偏好設定」的項目。實際上它們是兩則獨立的通知,各自帶著不同的 type 值;而只用 switch 判斷 type、卻沒有 default 分支的程式碼,就會忽略作者漏掉的那一種。

四種事件裡還有兩種的含意超出字面,其中一種甚至會改變 App 的驗證狀態。

重點摘要

Sign in with Apple 會送出四種伺服器對伺服器通知:email-enabledemail-disabledconsent-revokedaccount-deleted1 內容以 JWS 形式包在一個 JSON 物件的 payload 鍵底下,由 Apple 的私密金鑰簽章;在採取任何行動之前,必須依標頭 alg 參數所指定的演算法完成驗證。1 consent-revoked 會讓使用者的憑證失效,因此它是驗證事件,而非偏好設定變更。原生 App 在 Apple Account 遭刪除時收不到任何用戶端回呼,伺服器通知是唯一的訊號。1 至於重送與投遞語意,Apple 完全沒有寫進文件。

四種事件類型

每則通知都在 events 宣告(claim)裡帶一個 type 值。1

type 發生了什麼
email-enabled 使用者透過「隱藏我的電子郵件」啟用了轉寄到個人信箱的功能
email-disabled 使用者停用了電子郵件轉寄
consent-revoked 使用者撤銷了對您 App 的授權,其憑證隨即失效
account-deleted 使用者要求永久刪除自己的 Apple Account

被公告合併掉的,正是這兩個電子郵件事件。它們各有各的意義:email-disabled 代表您寄往轉寄位址的信件不再送達使用者,email-enabled 代表恢復送達。把兩者當成同一個「偏好設定已變更」事件,等於逼自己再回頭查詢目前狀態,而通知本身早就告訴您了。

兩種不能只看字面的事件

consent-revoked 是驗證事件。 Apple 的說明是,使用者「撤銷了您的 App 使用其 Apple Account 的授權,其憑證隨即失效」。1 不是即將淘汰,也不是等待過期,而是失效。

若把這則通知和電子郵件事件放在一起記錄、順手更新一列偏好設定,App 就會繼續呈現已登入的介面,背後卻是一組再也無法通過驗證的憑證。使用者會一直看到自己的帳號,直到下一次 token 更新失敗——然後看到更糟的畫面。正確的處理方式是結束該會話並導向重新驗證,就走您處理被撤銷 OAuth 授權時的同一條路徑。

account-deleted 可能是您唯一會收到的通知。 Apple 說明,當使用者永久刪除 Apple Account 時,Sign in with Apple 會讓所有使用者 token 失效,並停用所有相關 App 的電子郵件轉寄;而對原生 App,系統不會送出用戶端回呼。1

這句話正是「到底該不該架設端點」最有力的理由。只做 iOS、沒有伺服器對伺服器端點的團隊,根本沒有任何管道得知帳號已經消失。紀錄留在原地,轉寄位址失效,而您該履行的刪除義務也無從履行——因為沒有任何人告訴您有東西該刪。

註冊端點

設定的位置在 Certificates, Identifiers & Profiles:選擇 Identifiers,挑出您的 App ID,啟用 Sign in with Apple 服務,點選 Configure,然後填入端點 URL。2

在依這些限制設計架構之前,值得先把它們讀清楚。2

  • 每個 Sign in with Apple App 群組與金鑰只能有一個 URL。 不是每個 App 一個。
  • 只能註冊在主要 App ID 上。
  • URL 必須是包含 scheme、host 與 path 的絕對 URIhttps://example.com/path/to/endpoint
  • 接收通知必須使用 TLS 1.2 以上版本

API 的官方文件另外提到,同一個 URL 可以給多個開發團隊與多個 App 使用。1 對照「每個群組一個 URL」的規則,合理的解讀是:單一服務可以接收全部通知,各個群組則各自註冊指向它的位址。Apple 並未說明兩者如何互動,所以請把共用端點視為可行、而非官方背書;在真正依賴它之前,先確認您的處理常式能分辨每則通知屬於哪個 App。

TLS 1.2 這道下限,背後連著一個更大的轉向。OS 27 開始對管理流量強制執行更嚴格的 TLS 要求,同樣以 1.2 為最低標準,另外還要求符合 ATS 的加密套件與憑證。今天滿足 Apple 要求的端點,不代表自動符合 ATS,而整體走向是愈收愈緊,不是放寬。

解讀 payload 內容

投遞方式是一則 HTTP POST,請求主體是一個 JSON 物件,簽章後的 token 就放在裡面:1

{
    "payload": "<SERVER_TO_SERVER_NOTIFICATION_JWS>"
}

JWS 是被包起來的,不是裸的。 先解析 JSON、取出 payload,再進行驗證。把整個請求主體直接丟給 JWS 驗證器的實作,會在第一則通知就失敗,而且錯誤會表現成 token 格式有問題,而不是包裝方式弄錯——於是您會往錯的方向找。

驗證先於解讀。內容由 Apple 的私密金鑰以 JSON Web Signature 格式簽章,Apple 的指示是檢視該 JWS,並使用標頭 alg 參數所指定的演算法驗證簽章。1 簽章確認無誤之後,才讀取 events 宣告並依 type 分支。

有兩個一般 JWS 實務的習慣值得保留:絕不信任會讓呼叫端降級驗證的 alg 值;並確認 token 的簽發者與受眾符合預期,而不是照單全收任何格式正確、由 Apple 簽章的 token。

解碼後的結構

通過驗證後,一則解碼的 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 不同。電子郵件事件則多出兩個欄位:emailis_private_email

這個結構裡有三個細節,臨場才遇到會白白吃掉您的時間。

events 是物件,不是陣列。 名稱是複數,值卻只有一個事件。照名稱而不是照結構寫的程式碼,會去迭代一個字典,然後拿到一堆鍵。

is_private_email 是字串。 Apple 的範例寫的是加了引號的 "true",而不是 JSON 的布林值 true。嚴格的解碼器把它對應到 Bool 會直接失敗;寬鬆的解碼器把任何非空字串都當成真,則是碰巧答對,接著就會在 "false" 上答錯。

sub 是穩定的使用者識別碼,和登入時收到的是同一個值,也是您用來找出這則通知對應哪個帳號的依據。aud 則是您的用戶端識別碼,共用端點就是靠它把通知分派給不同的 App。

關於 Apple 自己的範例,還有一點要提醒:兩則電子郵件事件的內容在 "is_private_email": "true""event_time" 之間漏了一個逗號。把任一段複製進 JSON 解析器,都會被判定為無效文件。結構是對的,標點不對;把它貼進測試資料的讀者,會為了一個不是自己造成的語法錯誤損失十分鐘。

Apple 的用語也有出入。內文說內容採 JSON Web Signature 格式,外層包裝的範例卻把值命名為 SERVER_TO_SERVER_NOTIFICATION_JWT1 兩者指的是同一個東西;簽章後的 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 金鑰,全部是 RS256use: sig,並帶有不同的 kid。傳入 algorithms=["RS256"],而不是從 token 讀取 alg,就堵住了經典的降級路徑:攻擊者送來一個宣稱 alg: none 的 token。

kid 尋找金鑰,別直接拿第一把。 三把金鑰同時有效,這正是金鑰輪替從外部看起來的樣子。抓 keys[0] 的處理常式在 Apple 輪替之前都能運作,之後就會對部分 token 失敗,而那是非常難查的問題。

驗證 audiss 少了這兩項,您會接受任何簽章正確的 Apple token,包括為別的 App 簽發的那些。

保留 default 分支。 否則第五種事件類型就會憑空消失。照 Apple 那則三項公告寫成的實作,今天之所以漏掉 email-enabledemail-disabled,正是這麼來的。

Apple 沒有寫進文件的部分

無論是 API 的技術文件,還是帳號說明頁面,都沒有提到任何投遞保證。在兩份文件中搜尋「重送」「重新投遞」「確認回覆」「狀態碼」「逾時」與「冪等性」等字眼,一無所獲。

因此,截至撰稿時,Apple 的文件並未回答以下問題:

  • 投遞失敗會不會重送,重送幾次
  • 若會重送,時間窗有多長
  • 端點該回傳哪個狀態碼才算成功
  • 同一則通知會不會送達兩次

這個空白帶來設計上的後果,而以下是我在 Apple 明文之外的推論,不是轉述。投遞語意未定義的端點,不能當成權威的事件流。防禦性的做法是:把每則通知視為「有東西變了」的提示,再回頭與自己的紀錄核對,而不是盲目套用事件。處理常式要做到冪等,因為您無法排除重複投遞。也不要建立一套「必須收齊每一則通知才會正確」的流程,因為您無從確認自己真的收齊了。

如果端點停擺一小時,您無法從文件得知,究竟是遺失了一小時的帳號刪除事件,還是它們正排在某處等著送。就當作已經遺失來設計。

也沒有任何官方的測試方式

在這兩份頁面搜尋「沙箱」「模擬」與「觸發」等字眼,同樣沒有結果。Apple 沒有提供任何按需觸發通知的機制。

於是形成一個尷尬的循環。最要緊的兩種事件——consent-revokedaccount-deleted——都得由使用者撤銷對您 App 的授權,或刪除自己的 Apple Account 才會產生。要用真實的 account-deleted 驗證處理常式,就代表得有人真的刪掉一個 Apple Account。這種測試沒有人做第二次。

可行的替代方案是把問題拆成兩半。自己依前面的結構組出解碼後的內容,用單元測試檢驗分支邏輯、冪等性與核對邏輯。傳輸與簽章這條路徑則另外驗證,用一則您真的產得出來的通知:撤銷測試用 Apple Account 的授權是可以復原的,刪除帳號不是,而且這麼做會從頭到尾走過外層解析、kid 查詢與簽章檢查。

無論如何,在註冊之前,先確認端點可連線,而且能迅速回應。註冊了卻從未驗證的端點,正是某個團隊在數個月後才發現的事——上線以來的每一則通知,都送到了一個憑證早已過期的 URL。

一項已經生效的規定

這件事之所以會出現在開發者新聞裡,原因是:自 2026年1月1日起,位於大韓民國的開發者在註冊新的 Services ID 或更新既有的 Services ID 時,若要以 Sign in with Apple 將網站與 App 建立關聯,就必須提供伺服器對伺服器通知端點。3 Apple 於 2025年10月9日公布這項規定。

規定的適用範圍很窄,而且已經生效數月,不是還要準備的未來式。它真正的價值在於方向。Apple 已經開始在至少一個司法管轄區把端點列為必要,而背後的理由具有普遍性:讓人們掌控自己分享出去的個人資料,並讓帳號刪除真正傳遞下去。這套邏輯沒有一處只適用於韓國。

如果您反正都要做這個端點,就趁主管機關還沒把它變成您的期限之前先做完。

重點整理

給後端工程師: - 要處理四種類型,不是三種。email-enabledemail-disabled 是分開送達的。 - 先解析 JSON 主體、取出 payload,再把東西交給 JWS 驗證器。 - 讀取 events 宣告之前,先用標頭 alg 指定的演算法驗證簽章。 - 讓處理常式具備冪等性,並與自己的紀錄核對。Apple 沒有提供任何投遞保證。

給 iOS 團隊: - consent-revoked 會讓憑證失效。請把它當成結束會話的驗證事件,而不是偏好設定更新。 - 原生 App 在 Apple Account 遭刪除時收不到用戶端回呼。沒有端點,您永遠不會知道這件事發生過。

給還在考慮值不值得做的人: - 自 2026年1月起,這個端點對韓國開發者已是強制規定,而背後的理由具有普遍性。

常見問題

通知類型總共有幾種?

四種:email-enabledemail-disabledconsent-revokedaccount-deleted1 Apple 開發者新聞的公告寫的是三種,把兩個電子郵件事件併成同一條關於轉寄偏好設定的項目。3

使用者的憑證會失效。1 請比照被撤銷的 OAuth 授權來處理:結束會話並讓使用者重新驗證,而不是更新一個偏好設定就繼續下去。

如果我只推出原生 iOS App,還需要端點嗎?

Apple 說明,Apple Account 被永久刪除時,系統不會對原生 App 送出用戶端回呼。1 沒有伺服器對伺服器端點,就沒有任何機制會通知您。

端點該回傳什麼?如果它掛掉了怎麼辦?

Apple 沒有說明狀態碼的期待、重送行為,也沒有說是否會重複投遞。請以「至少一次、也可能至多一次」的投遞前提來設計,讓處理常式具備冪等性,並與自己的紀錄核對,而不是假設每則通知都送到了。

一個端點可以服務多個 App 嗎?

Apple 的文件寫道,同一個 URL 可以用於多個開發團隊與多個 App。1 而註冊的規則是:每個 Sign in with Apple App 群組與金鑰一個 URL,且必須在主要 App ID 上。2 只要您的處理常式能判斷每則通知屬於哪個 App,共用服務就是可行的。

資料來源


  1. Apple,“Processing changes for Sign in with Apple accounts.” 四種事件類型(email-enabledemail-disabledconsent-revokedaccount-deleted)、JWS 內容格式與「依標頭 alg 參數驗證」的指示、{"payload": "<JWS>"} 的包裝方式、撤銷授權會使憑證失效的說明、原生 App 在帳號刪除時收不到用戶端回呼的注記、TLS 1.2 的伺服器要求,以及同一個 URL 可跨多個團隊與 App 使用的允許事項,皆出自此處。擷取日期:2026年8月2日。 

  2. Apple,“Enabling server-to-server notifications.” 透過 Certificates, Identifiers & Profiles 註冊的路徑、每個 App 群組與金鑰一個 URL 的規則、主要 App ID 的限制、絕對 URI 要求與 TLS 1.2 要求,皆出自此處。擷取日期:2026年8月2日。 

  3. Apple Developer News,“New requirement for apps using Sign in with Apple for account creation,” 2025年10月9日。韓國自 2026年1月1日生效的規定,以及端點接收內容的三項摘要,皆出自此處。 

相關文章

那顆 fork bomb 救了我們

LiteLLM 攻擊者只犯了一個實作上的錯誤。正是這個錯誤,讓 47,000 次安裝在 46 分鐘內被逮個正著。

1 分鐘閱讀

程式碼倉庫不該為自己的信任投票

37天內出現兩個Claude Code信任對話框繞過CVE,揭示了載入順序的失敗。一條不變式即可解決:在路徑被信任之前,不解讀工作區內的任何位元組。

2 分鐘閱讀

我拒絕書寫的內容

部落格集群的聲音來自於它拒絕發表的內容,而非它發表的內容。範疇式、模式式與耐人尋味的拒絕,各自塑造了集群的樣貌。

1 分鐘閱讀