告别npm token:基于OIDC的零密钥发布npm包实战指南
2026/9/16 18:13:02 网站建设 项目流程

发布 npm 包这件事,最折磨人的从来不是写代码,而是“怎么证明你有权限发”。早期的方案很简单粗暴:生成一个 npm token,塞进 GitHub Secrets,然后在 CI 里用。但这个方案在我维护的几个开源小项目上反复翻车:token 过期了 CI 悄悄变红、token 权限给大了心里发怵、一不留神密钥泄漏还得连夜轮换。后来我把发布流程整体切到 Trusted Publisher + OIDC,彻底把“长期 token”从发布链路里拿掉了。这篇不是概念科普,是完整的实战复盘:原理怎么走、npm 后台怎么配、GitHub Actions 怎么写,以及我踩过的五个真实坑。

1. 为什么我决定把 npm token 彻底扔进垃圾桶

1.1 长期 token 时代的三个真实痛点

先用我自己维护的两个小库举例。一个工具 npm 包,一个组件库,常年靠 GitHub Actions 发版。传统配置长这样:先在 npm 后台生成一个 automation token,然后把这个 token 放到 GitHub 仓库的 Secrets 里,workflow 中通过NODE_AUTH_TOKEN注入给actions/setup-node,最后npm publish完成发版。

这套流程看起来顺滑,实际用起来全是暗坑。

第一,token 泄漏的风险被很多人低估了。npm 的 automation token 长期有效,只要进过 CI 日志、本地环境变量、或者某个.env文件,理论上就等于有人拿到了一把永不过期的大门钥匙。npm 官方其实有审计和通知机制,一旦检测到 token 出现在公开仓库里会主动撤销,但这种“事后补救”完全靠运气,真等被盗刷一次就够你喝一壶。

第二,token 的权限粒度不够舒服。npm 的 access token 分为几档,最常用的 automation token 拥有发布当前账号下所有包的权限。假如你账号下有个人包、公司包、甚至帮朋友维护的包,一个 token 全搞定,那它就成了单点故障。更细粒度的 granular access token 能限包,但创建和维护心智成本都不低。

第三,轮换成本看着小,实际很烦。npm token 默认会有有效期,到期后 CI 立刻红灯。你得重新登录、生成新 token、替换 secret,遇到多个仓库共用 token 的情况还得一个个排查。遇到“为什么突然发布失败”的问题,一半原因是 token 过期,另一半是权限被人改了,归根结底都是长期密钥的锅。

1.2 Trusted Publisher 的核心思路:短期凭证 + 边界绑定

Trusted Publisher 的思路其实一句话就能说清楚:不再把“长期密钥”交给仓库,而是让 CI 每次发布前,向 npm 证明“自己就是那个被授权的仓库和工作流”。这个证明用到的协议就是 OIDC,OpenID Connect。

打个比方。传统 token 相当于你给 CI 配了一把家里钥匙,只要钥匙不丢,谁拿到都能进门。Trusted Publisher 更像小区门口的人脸识别:CI 每次来,先刷一下脸(OIDC 拿短期身份),保安确认这人是本小区业主(claims 匹配),才放行,而且这次刷脸几分钟后就失效,下次还得重新刷。

这个方案的直接好处有三个:仓库 Secrets 里不再需要存 npm token,删掉一个长期密钥等于缩小一圈攻击面;权限被绑定到具体的仓库、工作流、甚至环境,权限边界比“一个账号 token”清晰得多;后续不需要轮换密钥,每次发布都会自动获取的新身份,过期就过期,不影响任何东西。

用到这套机制的不仅是 npm,GitHub 对主流云厂商、HashiCorp Vault、AWS 等都开放了 OIDC 通道,原理完全一致。你只需要理解一次,就可以迁移到其他发布场景。

2. OIDC 到底是怎么帮你“零 token”发布的

2.1 一次 OIDC 握手的完整过程

OIDC 的全称是 OpenID Connect,它是构建在 OAuth 2.0 之上的身份认证层。GitHub Actions、npm registry、以及你配置的 Trusted Publisher,三者之间会完成一次短期身份交换。

整个握手过程可以拆成四步:

  1. 发布工作流执行时,如果 job 声明了permissions: id-token: write,GitHub Actions 就会向自己的 OIDC provider 申请一个 JWT(JSON Web Token)。
  2. 这个 JWT 不是乱发的,里面携带了一组 claims,比如仓库全名(repository)、工作流文件名(workflow)、GitHub 环境名(environment)、触发方式(event_name)等关键属性。
  3. npm publish执行时,npm 客户端把 JWT 交给 npm registry,registry 先验签确认这个 JWT 确实由可信的 OIDC provider 签发,再对比 JWT 里的 claims 是否匹配你预先配置的 Trusted Publisher 条目。
  4. 验签通过、claims 匹配无误,registry 才为本轮发布会话发放授权,发布流程继续。

这里的核心是:JWT 是短期的,通常几分钟内过期;claims 是绑定了发布上下文的,不匹配就拒绝。就算某个环节有人拿到了 JWT,想用来二次发布也必须满足仓库、工作流、环境等多重条件,跟长期 token“一把钥匙开所有门”完全两个量级。

2.2 一张表看懂:传统 token 和 OIDC 的差异

对比维度传统 npm tokenTrusted Publisher + OIDC
凭证有效期长期有效,可能 1 年甚至更长分钟级,每次发布自动获取
存储位置GitHub Secrets、本地 .npmrc无需存储任何密钥
权限范围以账号或包为粒度,账号下多个包会一刀切绑定仓库 + 工作流 + environment
泄漏影响找到 token 即可冒充发布JWT 短暂有效且受多重条件约束
轮换成本定期手动重新生成不需要轮换
审计性token 归属较模糊每次发布与具体 workflow 强绑定

表格里有一点值得单独说:权限范围。传统 token 哪怕选了 granular access token,也只是“能发哪些包”的区别;而 Trusted Publisher 的匹配条件更刁钻,它要求 JWT 里的 repository 字段等于你配置的仓库,workflow 字段等于你配置的工作流文件路径,environment 也要一致,三重匹配少一个都发不出去。这就是为什么标题敢叫“零 token 发布”,因为发布凭据从“你拥有什么”变成了“你在哪个上下文里执行”。

2.3 为什么 claims 匹配是整套方案的安全基石

我刚开始接触 OIDC 时也犯过嘀咕:JWT 是短期没错,但它也是“一张纸”,万一伪造怎么办?其实不会。JWT 是带签名的,npm registry 拿到后会先通过 OIDC discovery 机制找到 GitHub 的公钥,验签不通过直接拒绝。常规篡改难度相当于你想伪造一张带芯片的身份证,而不是手写一张纸条。

再补一个容易忽略的细节:GitHub 对 OIDC 的 JWT 里有aud字段,也就是 audience。npm 文档要求配置的 audience 是registry.npmjs.org,这个字段会被 npm 客户端在请求时带上,进一步避免 JWT 被拿去请求别的服务。简单说,JWT 不是通用的万能票,它从头到尾就是奔着 npm registry 签发的。

理解这些之后,配置 Trusted Publisher 时才不会只停留在“照着文档点几下”的层面。你至少能应付一个常见问题:为什么别人抄了你的 workflow 仍然无法发布。答案很明显——他们不是配置里的那个仓库,claims 对不上,自然被拒。

3. 零 token 发布 npm 包:从零到一完整实操

3.1 前置准备:版本、仓库、包名

动手前先检查三样东西,缺一不可。

第一,Node.js 版本。npm 客户端对 OIDC 和 provenance 的支持从 npm 9 开始才逐渐完善,我建议直接用 Node.js 20 及以上版本,它内置的 npm 10.x 用起来最省心。如果你还在用 Node 16,后面--provenance参数几乎必踩坑,具体见第五节的坑三。

第二,GitHub 仓库已经推到远端,最好有一次成功跑通的 CI 记录。Trusted Publisher 配置需要填写仓库名和工作流文件名,如果仓库本身都没有,或者 workflow 还没提交,后端校验肯定过不了。

第三,包名需要在 npm 上可用。如果你发布的是已经存在的包,要确保自己的 npm 账号有该包的管理权限;如果是首次发布新包,先想好包名,最好在本机用npm view <package-name>查一下是否已被占用,避免发布时因为名字撞车而 403。

另外提一个 Git 相关的细节:每次发布前用git status确认工作区干净,打 tag 后触发发布。这个过程我会在 workflow 里用 tag 事件控制,避免每次 push 到主干都触发一次发版。

3.2 在 npm 后台把仓库授权为 Trusted Publisher

登录 npm 官网,进入对应包的设置页。这里要注意,Trusted Publisher 是配在包级别的,不是配在账号级别,所以一定要先切到你要发布的那个包。

步骤如下:

  1. 打开https://www.npmjs.com/package/<你的包名>,确认自己已登录且是该包的管理者。
  2. 在页面里找到包的设置入口(Package Settings 那一带),找到 “Trusted Publishers” 区块。
  3. 点 “Add Publisher” 或类似的新增按钮,开始填写授权信息。
  4. Repository 一栏填 GitHub 仓库的全名,格式是owner/repo,例如zhangshan/awesome-cli,不带.git后缀。
  5. Workflow name 一栏填你即将使用的 workflow 文件名,例如publish.yml。注意这里要填完整的文件名.yml,不是 workflow 内部的name字段,也别带路径前缀。
  6. Environment name 是可选的。如果你准备在 GitHub Actions 里用 environment 做权限隔离,比如生产环境叫npm-publish,这里必须填完全相同的名字;不填则表示匹配不限定 environment。

提交之后,npm 后台就多了一条 Trusted Publisher 记录。它会显示授权给哪个仓库、哪个工作流、哪个环境,后续 npm registry 就是拿 JWT 里的 claims 和这条记录比对。

这里有一个我刚才实际踩过的小细节:如果你填完 Repository 还没保存,页面可能提示需要先验证你对仓库的所有权。不同时期的 npm 后台校验方式不完全一样,有的直接通过 GitHub OAuth 验证身份,有的需要稍等片刻。正常情况下一两分钟内就能绑定成功。

3.3 编写发布用的 GitHub Actions

接下来是在仓库里创建.github/workflows/publish.yml,工作流内容如下:

name: publish on: push: tags: - 'v*' permissions: contents: read id-token: write jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm ci - name: Publish to npm run: npm publish --provenance --access public

逐行拆一下关键点。

permissions区块是整个零 token 方案的灵魂。id-token: write是 GitHub Actions 允许 workflow 向 OIDC provider 请求身份令牌的总开关,没有它,后续所有 OIDC 行为都会失败。contents: read则是最小化权限,确保 checkout 有读取权限的同时,不让 workflow 乱动仓库。

actions/setup-node这次不再需要token输入,也没必要往 Secrets 里放NPM_TOKEN。它只需要配置好registry-url,npm 客户端后续才会去向registry.npmjs.org做认证和发布。

npm publish --provenance --access public里有两个参数都值得解释。--provenance开启构建来源证明,npm 会在发布时通过 OIDC 生成的 JWT 与 sigstore 体系联动,生成一份 SLSA 级别的来源声明,这对开源包的用户来说等于多了一个“这包确实从这个仓库构建发布”的信任凭证。--access public则是对第一版发布到 npm 的包最稳妥的保险,尤其是 scope 包,不显式声明容易默认为 restricted,免费账号下直接报错。

触发条件用了push: tags: - 'v*',意味着只有当你推送形如v1.0.0的 tag 时才会走发布流程。日常提交代码不会触发发布,这个习惯在多仓库场景下非常重要。推送 tag 的命令是git tag v1.0.0 && git push origin v1.0.0

3.4 发布之后的验证清单

发布跑完之后,不要只盯着绿色勾,要主动验证几项。

第一,确认包确实已更新到 npm。执行npm view <包名> versions,能看到最新版本出现即可。第二,检查 provenance 是否生效。在 npm 官网包页面找到 “Provenance” 信息块,能看到一个 “signed” 的状态和对应构建地址,这表示发布链路确实走了 OIDC。第三,顺手看下 GitHub 仓库里的 Secrets 页面,确认没有任何 npm token 类变量,这是“零 token”的最终证据。

如果这三项都通过,恭喜你,发布链路已经彻底摆脱长期 token 了。

4. 五个真实的坑,请直接抄进你的 check-list

4.1 坑一:忘记了 id-token: write,OIDC 直接被拒

这不是配置错误,是权限设计导致的第一步就失败。我一开始把 workflow 里的permissions区块写成了传统发布方式的样子,只有contents: read,结果npm publish --provenance直接报错,日志里明确提示The workflow is not allowed to access the OIDC token类似的字样。

当时我还以为是 npm 后台配置没生效,反复删了重加 Trusted Publisher,折腾了快一个小时才发现是这里的问题。原因不复杂:GitHub Actions 默认上下文中没有 OIDC 权限,必须通过id-token: write显式授予。

修复方式就是给 workflow 配置完整的 permissions:

permissions: contents: read id-token: write

如果你习惯用 GITHUB_TOKEN 做其他事,也可以按需追加,但id-token: write不能少。另外需要注意,这个 permissions 是 job 级还是 workflow 级?我建议直接写在 workflow 顶层,让所有 job 都有统一预期,除非你刻意隔离。

4.2 坑二:workflow 文件名对不上,发布 403

这个问题我帮朋友排查过一次,他自己怎么也找不到原因。症状是npm publish报 403,日志里的错误信息跟权限不足相关,看了半天 npm 后台的 Trusted Publisher 配置也确实是配好的,但就是发不出去。

最后对来对去,发现 npm 后台填的是publish.yml,实际仓库里的文件是.github/workflows/release.yml。JWT 里的 workflow claim 是完整的文件名,npm registry 拿它和你配置的字符串做严格比对,差一个字母都不行。

修复办法很简单:去 npm 后台把 Workflow name 改成release.yml,或者把仓库里的 workflow 文件重命名为publish.yml。我更推荐后者,因为保持仓库内文件名和 Trusted Publisher 配置一致,以后接手的人一眼就能对上。

这里还衍生出另一个小坑:如果你改过 workflow 文件名,旧配置不会自动同步,记得去后端删掉旧的 Trusted Publisher 记录再新增,避免残留两条看似重叠又都不完全匹配的配置。

4.3 坑三:npm 版本太旧,--provenance 不认账

有一种报错特别容易误导人:Unknown argument: provenance。你搜日志、改配置,怎么想都不会想到是 Node 版本太低。

我最初在某个旧项目的 GitHub Actions 里用了node-version: '16',它内置的 npm 8.x 根本不认识--provenance参数,所以 npm publish 直接拒绝执行。这种问题跟注册表配置无关,跟权限无关,纯粹是 CLI 版本不支持新特性。

我的建议:零 token 发布方案里,Node.js 版本直接用'20''22',对应的 npm 10.x 对 OIDC 和 provenance 的支持最完整。如果你实在要维护老项目,最低也别低于 Node 18(npm 9.x),再旧就建议你把发布流程单独拆出来,用新的 Node 版本跑发布 job。

在 workflow 里修改 node-version 后记得重新提交、打 tag 再试一次,不要只在本地验证。本地 Node 版本没问题不代表 Actions 里的版本没问题,CI 环境和你本地环境是两个世界。

4.4 坑四:残留 NPM_TOKEN 悄悄抢走了发布权

这个坑最阴,因为它根本不会报错。有一次我明明已经配好了 Trusted Publisher,release workflow 也跑到了发布步骤,但发布失败的信息是401 Unauthorized,看起来就像 token 过期问题。

排查到最后才发现,旧 workflow 里actions/setup-nodewith中残留了token: ${{ secrets.NPM_TOKEN }}这一行。setup-node 拿到这个 token 后会在.npmrc里写入//registry.npmjs.org/:_authToken=<token>,npm 客户端首先尝试用它认证,认证失败或权限不符时,并不会自动降级走 OIDC,而是直接抛 401。

这里的关键教训是:只要.npmrc里有 token,npm 客户端就会优先使用 token,Trusted Publisher 的 OIDC 通道根本不会被触发。你照着文档配了一遍“零 token”,实际跑的还是老一套,只是 token 过期了才露出马脚。

修复分两步:第一步,删掉 workflow 里 setup-node 的token输入;第二步,去 GitHub 仓库 Settings → Secrets and variables → Actions 删除NPM_TOKEN旧变量。顺手检查仓库根目录的.npmrc~/.npmrc,确保没有残留_authToken字段。

4.5 坑五:跨 job 复用产物时,environment 不匹配

项目复杂一点之后,很多人会把构建和发布拆在两个 job 里:先 build,用 actions/upload-artifact 存产物,再在 publish job 里 download,最后发布。这个设计本身没问题,但如果你给发布 job 指定了environment,就要特别注意 Trusted Publisher 的一致性。

我当时在 npm 后台配置 Trusted Publisher 时填了 environment 为npm-publish,GitHub Actions 里也是这么写的:

jobs: publish: runs-on: ubuntu-latest environment: npm-publish needs: build steps: - run: npm publish --provenance --access public

表面看没毛病,报错却出现在 token exchange 阶段:token exchange failedsubject mismatch。原因在于 OIDC JWT 里的 environment claim 和 Trusted Publisher 配置的 environment 必须完全一致,而且 GitHub 侧执行该 job 时确实会注入 environment 信息。一旦你在 npm 后台写的是npm-publish,但代码里写的是npm-publish-prod,就会被拒。

如果你确实不需要环境隔离,最简单的做法就是不填 environment。但按我现在的经验,发布这种高危操作用 environment 保护更稳妥,建议配置一把锁:

  1. 去 GitHub 仓库 Settings → Environments 创建npm-publish环境,可以加上保护规则,比如仅允许从主分支发布。
  2. npm 后台 Trusted Publisher 的 Environment name 填npm-publish
  3. workflow 里发布 job 显式带environment: npm-publish

这样 OIDC claims、GitHub environment、npm 后台配置三项统一,不仅能正常发布,还多一层环境级保护。跨 job 发布时还有个隐性优势:发布 job 受 environment 保护规则约束,别人想临时手动触发也得过环境关卡。

5. 迁移到零 token 发布之后的几点体会

整套流程切完之后,我最直观的感受不是“省了一个 secret”,而是 CI 报错归因变得特别干净。以前发布失败要查 token 是否过期、权限是否有变、secret 是否被误删,现在只需要看 workflow 里 OIDC 链路的三板斧:有没有id-token: write、npm 后台的 repository/workflow/environment 是否和实际一致、npm 版本够不够新。问题范围缩小一大半。

还有一个小建议给维护团队。Trusted Publisher 授权的是仓库级发布权,改动这份配置的权限比普通 workflow 改动重要得多。如果你用 CODEOWNERS 管理仓库,建议把.github/workflows/publish.yml以及 npm 后台配置流程归到固定的 core maintainer 名下,避免临时提 PR 的人顺手改掉发布边界。

最后分享一个我后来发现的小技巧:如果你有多个 npm 包想都改成零 token 发布,不需要挨个在 npm 后台操作,可以确认每个包在同一仓库中是否有相同的 Trusted Publisher 配置。只要仓库的发布 workflow 能同时构建多个包,npm 后台每个包各自加一条 trusted publisher 即可,配合npm publish --workspace能一次性发多个包,发布入口也保持单一,安全边界依然清晰。

我把这套方案落地之后,GitHub Secrets 里再也没有任何 npm 相关凭证,新同事接手项目也不需要走“找 token”流程。零 token 并不是“没有认证”,而是把认证从“长期密钥”变成了“每次实时证明身份”。这两种思路对发布链路的影响,只有真正跑过一遍才会体会得到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询