
PyPI 用 GitHub OIDC 短期身分把發布權限綁定到可稽核工作流程。
我在第一次發布 PyPI 專案時,參考了官方文件。在文件的指引下,還沒完全消化吸收,就迷迷糊糊地完成了專案的發布。
前幾天要再次發布 PyPI 專案時,感覺還是不是很清楚,又重新找了對應的文件,不知不覺又耗費了 1–2 個小時。茫然之餘,覺得自己又欠下了技術債,今天決定把這個發布流程徹底搞清楚:看看 PyPI 究竟是如何信任 GitHub 的?GitHub 又如何讓 PyPI 認可其提交的發布內容?而且這中間的互動過程,竟然完全看不到 PyPI API Token 的影子。
傳統 PyPI API Token 能證明「有人持有上傳憑證」,卻不能證明「這次上傳來自哪一個 GitHub 工作流程」。
PyPI API Token 是一段長期有效的共享金鑰。開發者將它儲存為 GitHub Secret,當工作流程執行時再將其注入至發布步驟。上傳工具帶著 Token 發送發布請求,PyPI 則驗證 Token 是否有效,以及它是否擁有目標專案的上傳權限。
這套機制解決的是「誰持有憑證」,但它解決不了「這份憑證是由誰、在什麼現場使用」。
正在渲染 Mermaid 圖表...
例如,PyPI 通常無法僅憑 Token 判斷:
這不是 PyPI 少做了一次 GitHub 查詢,而是 Token 本身的设计邊界。Token 建立後可以被複製到本機電腦、其他 CI 系統或另一個工作流程中。只要金鑰依然有效,PyPI 就難以區分這是預期中的發布,還是憑證外洩後的上傳。
這就像辦公室的金鑰。門鎖能確認金鑰是否正確,卻不知道開門的人從何而來,也不知道金鑰是否已被複製。這個比喻只說明了「持有憑證」這件事——API Token 不像帶有照片與有效期限的門禁卡,它本身無法描述當前存取的執行現場。
API Token 仍有其適用場景。例如本機人工發布、非 GitHub 的 CI 系統,或是尚未支援 OpenID Connect(OIDC)的自動化環境,都依然可以使用它。
但代價也相當明確:Token 必須妥善保存、定期輪替與撤銷。一旦 Token 意外寫入日誌、建置產物或不可信的腳本中,攻擊者就能在有效期內直接上傳套件。即便將 Token 的權限限縮至單一 PyPI 專案,它依然無法證明呼叫者到底是哪一次 GitHub 執行。
許多人常誤以為「只要將 Token 設為儲存庫層級的 Secret,就能限制發布來源」。但事實上,儲存庫層級的 Secret 限制的只是設定位置,而非發布身份。只要能讀取該 Secret,或是能間接呼叫包含該發布步驟的工作流程,都有可能使用這把金鑰。
Trusted Publishing(可信發布)改變了驗證的對象。它並非讓 GitHub 以使用者身份登入 PyPI,而是讓 PyPI 信任 GitHub 為當前這一次工作流程執行所簽發的短期身分 Token。
這就像門禁系統核對臨時訪客證:門禁系統需要確認簽發公司是否可信、證件是否尚未過期,以及訪客資訊是否符合預約紀錄。這與把公司大門金鑰交出去截然不同,因為該訪客證僅對應一次性且短期的存取。
PyPI 會預先登記允許發布者的身分約束條件。這些約束可以關聯至特定 GitHub 擁有者、儲存庫以及工作流程檔案。GitHub 則會在工作流程執行時,提供一份描述當前任務的短期身分 Token。PyPI 僅會接受同時符合這些約束條件的上傳請求。
在 PyPI 的帳戶設定頁面 Publishing 中,可以登記各種支援 OIDC 的發布來源。
正在渲染 Mermaid 圖表...
這樣的設計,是為了將發布權限綁定至執行上下文,而非一段可被複製的秘密金鑰。即使請求確實來自 GitHub,也不能僅因「來自 GitHub」就直接允許發布;它還必須精確符合 PyPI 預先登記的規則,否則任何 GitHub 儲存庫都能冒充發布源。
更重要的是,OIDC 只能證明自動化執行的上下文,無法證明建置產物本身的安全。若攻擊者能夠修改受信任的工作流程、掌控可發布的分支,或繞過發布審核,依然可能利用合法身份上傳惡意版本。因此,程式碼審查、分支保護與審批機制,並無法被 Trusted Publishing 完全取代。
PyPI 信任的不是「GitHub 傳來的請求」,而是「GitHub 為指定工作流程簽發,且仍在有效期限內的身分憑證」。
上一章已經說明,必須先在 PyPI 帳戶下完成專案發布者登記。完成登記後,對應的 OIDC 才能用於上傳與發布。
在 GitHub Actions 中,id-token: write 允許某個作業在執行期間請求 OIDC Token。它不是儲存庫的寫入權限,也不會將令牌儲存在 Secrets 中。
可以把它想成櫃檯為已完成登記的訪客列印臨時證。臨時證會根據訪客當次到訪的資訊產生,並且具有有效期限;它不像是櫃檯把能永久開門的鑰匙交給訪客。
發布工作宣告這項權限後,GitHub OIDC Provider(OIDC 身分提供者)會根據目前的執行環境簽發 JSON Web Token(JWT,一種帶有簽章的身分聲明)。發布 Action 會請求面向 PyPI 的 Token,再以此交換本次上傳所需的短期授權。
JWT 中包含發行者、受眾與到期時間,也會攜帶儲存庫、工作流程、執行環境等內容聲明。具體的聲明欄位與 PyPI 的比對規則,應分別以 GitHub Docs 的《OpenID Connect reference》及 PyPI 的《Trusted Publishers》為準。
write 這個名稱容易造成誤解。它表示「允許請求身分令牌」,並不是「允許修改 GitHub 的 OIDC 身分設定」。
相應的風險在於,取得 id-token: write 的工作可以向任何接受 GitHub OIDC 的第三方請求令牌。因此,不應將這項權限授予不相關的作業,也不應讓它與不受信任的建置指令碼共用同一個作業。否則,即使指令碼無法取得 PyPI API Token,仍可能利用該作業的身分向外部服務進行驗證。
PyPI 不需要在每次發布時回頭向 GitHub 查詢儲存庫內容。它會先驗證 JWT 確實由 GitHub 簽發,再檢查令牌中的聲明是否符合自己保存的可信發布者規則。
正在渲染 Mermaid 圖表...
驗證順序非常重要。PyPI 會先使用 GitHub OIDC 發行者的公開簽章金鑰,驗證 JWT 的來源與完整性,並檢查受眾及有效期限。接著才會比對儲存庫、工作流程等身分聲明。
常見的誤解是,PyPI 會直接讀取 GitHub 儲存庫,或根據提交資訊判斷權限。實際上,PyPI 驗證的是由 GitHub 簽署的身分聲明,以及 PyPI 自己保存的登記規則。
短期 Token 可以縮短憑證外洩後的可利用時間,但也要求驗證與上傳必須在同一次有效的執行期間完成。網路重試、執行環境的時鐘異常,或 PyPI 與 GitHub 之間的設定不一致,都可能導致發布在驗證階段失敗。
短期有效並不代表「永遠不會出錯」,而是透過更嚴格的時序限制,換取更小的憑證暴露面。
將建置封裝與發佈上傳拆分為獨立作業,核心目的是隔離 OIDC 身分權杖的請求權限。
在 GitHub Actions 中,permissions: id-token: write 控制作業向 GitHub OIDC 提供者請求 JSON Web Token(JWT)的能力。若將建置與發佈放在同一個作業中,該作業內的所有步驟都能呼叫內部 API 取得身分權杖。
拆分作業後,建置與編譯等步驟不授予 OIDC 權杖請求權限;發佈作業則個別宣告 id-token: write,專門負責下載產物,並向 PyPI 提交 OIDC 驗證。
在這項設定中,只有 publish 作業可以向 GitHub 請求 OIDC JWT。宣告 environment: pypi 後,GitHub 會在簽發的 JWT 中注入對應的環境身分宣告。這項環境身分宣告必須與 PyPI 端登錄的可信發佈者規則完全一致。
驗證 OIDC 身分鏈是否正常運作時,絕對不能在記錄中輸出 JWT 明文。雖然 OIDC JWT 的有效期限只有幾分鐘,但在到期前公開,仍可能造成憑證外洩。若要觀察身分鏈的建立狀態,應搭配 GitHub Actions 執行記錄與 PyPI 設定進行判斷。
在 GitHub Actions 執行記錄中,應特別關注發佈 Action 的驗證階段。若作業缺少 id-token: write 權限,GitHub OIDC 提供者會拒絕簽發 JWT。若 PyPI 拒絕授權交換請求,日誌通常會顯示 HTTP 400 錯誤。這類失敗通常是因為 JWT 內含的儲存庫名稱、作業流程路徑或環境名稱與登錄規則不一致。
可信發佈(Trusted Publishing)的本質,是以短期 GitHub OIDC JWT 取代長期靜態 API Token。這能消除靜態金鑰在 CI 機密儲存區中外洩,或遭人在離線環境竊用的風險。然而,OIDC 只能驗證「請求來自哪個 GitHub 執行環境」,無法驗證「程式碼邏輯是否安全」。如果攻擊者取得儲存庫的修改權限,仍然可以透過修改作業流程,發起合法的 OIDC 上傳。
PyPI 用 GitHub OIDC 短期身分把發布權限綁定到可稽核工作流程。