
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 被限制到单个 PyPI 项目,它仍不能说明调用者是哪次 GitHub 运行。
常被误以为“设为仓库级 Secret,就限制了发布来源”。其实,仓库级 Secret 限制的是配置位置,不是发布身份。能读取它,或能间接调用持有它的发布步骤的工作流,仍可能使用这把钥匙。
Trusted Publishing(可信发布)改变了验证对象。它不是让 GitHub 以用户身份登录 PyPI,而是让 PyPI 信任 GitHub 为本次工作流签发的短期身份令牌。
可以把它看作门禁系统检查临时访客证。门禁需要确认签发公司可信、证件尚未过期、访客信息匹配预约。它不像把公司主钥匙交出去,因为证件只对应一次短期访问。
PyPI 预先登记允许发布者的身份约束。约束可关联某个 GitHub 所有者、仓库和工作流文件。GitHub 在工作流运行时提供描述当前任务的短期身份令牌。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 的令牌,再用它交换本次上传所需的短期授权。
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 与 GitHub 配置不一致,都会使发布在认证阶段失败。短期性不是“永不出错”,而是用更严格的时序约束换取更小的凭证暴露面。
将构建打包与发布上传拆分为独立作业,核心目的是隔离 OIDC 身份令牌的请求权限。
在 GitHub Actions 中,permissions: id-token: write 控制着作业向 GitHub OIDC Provider 请求 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 Provider 会拒绝签发 JWT。若 PyPI 驳回了授权交换请求,日志通常会抛出 HTTP 400 错误。这种失败通常源于 JWT 携带的仓库名、工作流路径或环境名与登记规则不符。
Trusted Publishing 的本质,是用短期 GitHub OIDC JWT 替代长期静态 API Token。它消除了静态密钥在 CI 密钥库中泄露或离线被盗用的风险。然而,OIDC 只验证“请求来自哪个 GitHub 执行上下文”,不验证“代码逻辑是否安全”。攻击者若获取了仓库修改权限,依然可以通过修改工作流发起合法的 OIDC 上传。
PyPI用GitHub OIDC短期身份把发布权限绑定到可审计工作流。