一、先说清楚:CI 环境里的 npm token 到底是个啥
很多刚接触前端工程化的朋友,第一次在 CI 配置里看到 NPM_TOKEN 这个环境变量时,心里会犯嘀咕:这不就是个密码吗?直接填进去不就行了?其实没那么简单。npm token 是 npm 官方提供的一种访问凭证,用来替代用户名和密码,专门给自动化工具用的。在本地开发时,你登录 npm 之后,npm 会把凭证存在用户目录下的 .npmrc 文件里,你在终端敲 npm publish 的时候,它自动帮你完成身份验证。但是在 CI 环境里,没有人坐在电脑前输入密码,所以 CI 系统需要预先拿到一个 token,然后把这个 token 放到环境变量里,让 npm 命令在打包、发布、甚至安装私有包的时候都能顺利通过验证。
这个 token 就好比你家的门禁卡。你要是把门禁卡直接贴在大门口,谁路过都能刷一下进你家,那肯定不行。CI 环境里的 token 如果管理不当,后果轻则泄露私有代码,重则被人利用往 npm 上发布恶意版本,影响所有下载你包的人。所以,学会正确生成、安全管理和最小化权限,是每个和 CI 打交道的前端开发者都必须掌握的基本功。
二、token 的三种典型应用场景
2.1 场景一:发布 npm 包到公共仓库
这是最常见的需求。项目维护者希望每次往 main 分支合入代码后,CI 自动帮我们跑测试、打版本、然后 npm publish 发布到 npm 官方源。这个场景下,token 需要具备对指定包的发布权限。但是要注意,npm 的 token 权限是分级的,不是说你有一个 token 就能发布所有包。你可以只给这个 token 分配“发布某个 scope 下的包”的权限,这样就算 token 不小心泄露了,攻击者也最多只能发布你指定命名空间下的包,伤害范围可控。
2.2 场景二:CI 安装私有包
如果你的项目依赖了存放在 npm 私有仓库(比如 GitHub Packages、Azure Artifacts、Verdaccio 等)里的包,那 CI 在跑 npm ci 的时候就需要有权限去下载这些包。这时候 token 的权限应该是“读取”级别,而不是“发布”级别。很多人图省事,把发布用的 token 也拿来做安装用,这是非常危险的。因为安装流程可能在每次构建时都会触发,token 出现在日志里的概率更高,一旦泄露,攻击者拿到的是一个有发布权限的 token,后果不堪设想。
2.3 场景三:在多平台 CI 上共享同一个 token
比如你同时用 GitHub Actions 和 GitLab CI 跑同一个项目,你可能希望两边用同一个 npm token,减少维护成本。这种情况下,token 的存储位置和命名约定就要格外统一。不能只在一个平台设置环境变量,另一个平台却写死在代码仓库里。更好的做法是,把 token 存在各平台的 secret 管理功能中(比如 GitHub Secrets、GitLab CI Variables),然后在 CI 运行前动态地把它写进临时 .npmrc 文件里,用完就删。
三、如何生成一个安全可控的 npm token
在介绍具体命令之前,我们要先选一个统一的技术栈。本文所有示例都使用 Node.js + npm + GitHub Actions 来演示。其他 CI 平台(如 GitLab CI、Jenkins)思路完全一样,只是配置格式稍有不同。
3.1 使用 npm 官网生成粒度 token
登录 npm 官网(https://www.npmjs.com),点击右上角头像,选择 "Access Tokens",然后点击 "Generate New Token"。你会看到几个选项:
- Classic Token:这是旧版 token,可以选择权限范围(Read / Read and Write / Publish)。
- Granular Access Token:这是新版粒度 token,可以指定 token 只能访问某些包,还能设置过期时间。强烈推荐使用这种。
我们选择 Granular Access Token,然后按下面步骤配置:
- Token 名称:填一个能让你自己看明白的名字,比如
ci-publish-for-my-app。 - 权限:选择 "Read and Write",因为发布包需要写权限。
- 包范围:选择 "Only select packages",然后输入你的包名,比如
@your-scope/my-app。 - 过期时间:建议设置 90 天或 180 天。不要让 token 永久有效,否则长期泄露的风险很高。
点击生成后,npm 会给你一串以 npm_ 开头的字符串,例如 npm_abcdefghijklmnopqrstuvwxyz123456。这个字符串只在生成时显示一次,之后再也看不到了,所以务必立即把它存到你的密码管理器里。
3.2 使用命令行生成 token
如果你更喜欢用命令行,也可以直接通过 npm token create 命令来生成。但要注意,npm 命令行默认生成的通常是 classic token,而且权限是 "Read and Write",所以我们需要稍微配置一下。
# 先登录,注意 --scope 指定你自己的 scope,避免影响全局登录状态
npm login --scope=@your-scope --registry=https://registry.npmjs.org
# 创建一个只读 token(用于 CI 安装私有包)
npm token create --read-only --cidr=192.168.1.0/24
# 创建一个读写 token(用于 CI 发布包),并限制 IP 段
# 注意:cidr 参数是可选的,但强烈建议加上,限制 CI 出口 IP
npm token create --read-write --cidr=202.96.128.0/24
# 查看当前所有 token
npm token list
# 删除某个 token(后面跟 token ID)
npm token delete npm_abcdefghijklmnopqrstuvwxyz123456
上面的命令中,--cidr 是用来限制来源 IP 地址的,相当于给 token 加了一把额外的锁。如果你的 CI 服务商有固定的出口 IP(比如 GitHub Actions 使用的 IP 段是公开的),你可以把这些 IP 段填进去。这样即使 token 被别人拿到,只要对方的 IP 不在白名单里,照样用不了。
四、在 GitHub Actions 里管理 npm token
4.1 把 token 存到 GitHub Secrets
不要在 CI 配置文件里直接写 token 明文,这是底线。在 GitHub 仓库的 Settings -> Secrets and variables -> Actions 里,点击 "New repository secret",把名字命名为 NPM_TOKEN,值粘贴刚刚生成的 token。存储之后,在 GitHub Actions 的 workflow 文件里,通过 ${{ secrets.NPM_TOKEN }} 来引用它。
4.2 在 workflow 中安全地写入 .npmrc
很多人喜欢在 workflow 里用 echo //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} > .npmrc 这样的方式写入 token。这其实有一个小风险:在 CI 日志里,如果你不小心把 echo 的内容打出来了,token 就会泄露。GitHub Actions 会自动把 secrets 的值打码成 ***,但保险起见,还是建议用下面的方式,把 token 放到环境变量里,然后让 npm 自己读取。
name: CI
on:
push:
branches:
- main
jobs:
publish:
runs-on: ubuntu-latest
steps:
# 第一步:拉取代码
- uses: actions/checkout@v4
# 第二步:安装 Node.js,指定版本
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
# 第三步:安装依赖
- name: Install dependencies
run: npm ci
# 第四步:运行测试
- name: Run tests
run: npm test
# 第五步:构建项目
- name: Build
run: npm run build
# 第六步:发布到 npm
- name: Publish to npm
run: npm publish
env:
# 重点:这里不使用 registry-url 里的自动配置,而是显式设置环境变量
# npm 会从 NODE_AUTH_TOKEN 里读取凭证
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
在上面的示例中,actions/setup-node 会自动帮我们生成一个 .npmrc 文件,里面使用的是 NODE_AUTH_TOKEN 环境变量来填充认证信息。你不需要手动创建 .npmrc 文件,这样就不会在日志里暴露任何 token 内容。注意,npm publish 的时候,npm 会自动使用这个环境变量完成认证,非常简单。
4.3 最小化 token 在 CI 中的可见范围
一个常见的问题是:在 workflow 中,如果多个 step 都需要用到 token,你是不是在每个 step 里都设置 env?没必要,而且会增加泄露面。正确做法是只在真正需要认证的 step 里设置。比如上面的示例中,只有 Publish to npm 这一步需要 token,其他步骤都不需要。如果你在 Install dependencies 这一步安装的是公共包,那就不需要 token,也不要设置 NODE_AUTH_TOKEN。
不过,如果你要安装私有包,那么 npm ci 这一步也需要 token。此时你可以把 token 作为 npm ci 步骤的环境变量传入,但注意,npm ci 会读取项目根目录下的 .npmrc 文件或者 NODE_AUTH_TOKEN 环境变量。为了做到权限最小化,建议为安装私有包单独创建一个只读 token,不要和发布用的 token 混用。
4.4 使用 .npmrc 配置文件限定作用域
有时候你的项目既需要安装公共包,也需要安装私有包。公共包直接走默认 registry,私有包走你的私有 registry。这时候我们可以用一个 .npmrc 文件,把私有包的 scope 映射到私有 registry 上,并且只在需要的目录下配置 token。
# 项目根目录下的 .npmrc 文件
# 这个文件会提交到 Git 仓库,所以绝对不能包含 token 明文
@your-scope:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
always-auth=true
注意,${NPM_TOKEN} 是从环境变量里读取的,不会写在文件里。在 CI 中,你只要在运行 npm ci 之前设置好 NPM_TOKEN 环境变量即可。这样,token 就不会出现在仓库历史记录中,也不会出现在任何配置文件中。
五、权限最小化的具体实践
5.1 按用途拆分 token
前面已经反复提到了,要按用途拆分 token。至少拆成两个:
- 一个只读 token:用于 CI 安装私有依赖。
- 一个读写 token:用于 CI 发布包。
如果你的团队还有人对 npm 包进行手动发布(比如发布 hotfix),那最好再单独创建一个个人用的 token,并且只给他分配 "Read and Write" 权限,只授权那几个需要维护的包。不要使用团队成员共用的 token,因为一旦有成员离职,你就需要重新生成 token,并且更新所有 CI 配置,非常麻烦。
5.2 设置 token 过期时间
npm 的 granular access token 支持设置过期时间,从 1 天到 1 年不等。建议 CI 使用的 token 设置为 90 天左右。同时,在你的 CI 流水线里加一个“过期检查”步骤,提前提醒你 token 快要失效了。下面是一个简单的检查脚本,可以放在 scripts/check-token-expiry.js 中。
// scripts/check-token-expiry.js
// 这是一个 Node.js 脚本,用来检查 npm token 的过期时间
// 使用方法:node scripts/check-token-expiry.js
// 引入必要的模块
const { execSync } = require('child_process');
try {
// 执行 npm token list 命令,获取 token 列表
const output = execSync('npm token list --json', { encoding: 'utf-8' });
// 把输出解析成 JSON 对象
const tokens = JSON.parse(output);
// 遍历所有 token
tokens.forEach((token) => {
// token 对象里有 expires 字段,格式是 ISO 时间字符串
const expires = new Date(token.expires);
const now = new Date();
// 计算距离过期还有多少天
const daysLeft = Math.ceil((expires - now) / (1000 * 60 * 60 * 24));
// 如果少于 30 天,打印警告
if (daysLeft < 30) {
console.warn(`⚠️ Token ${token.id} 还有 ${daysLeft} 天过期,请及时更新!`);
} else {
console.log(`✅ Token ${token.id} 还有 ${daysLeft} 天过期。`);
}
});
} catch (error) {
// 如果命令执行失败,比如没有权限或未登录,输出错误信息
console.error('无法获取 token 列表,请检查是否已登录 npm。');
console.error(error.message);
process.exit(1);
}
然后在 CI 中,在发布之前先执行这个检查脚本:
- name: Check token expiry
run: node scripts/check-token-expiry.js
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
这样如果 token 快过期了,CI 会提前报错,提醒你及时更换。
5.3 限制 token 可访问的 IP 范围
前面我们在生成 token 时提到了 --cidr 参数。如果你的 CI 平台有固定的出口 IP,一定要用起来。比如 GitHub Actions 的 IP 段可以在 GitHub 官方文档里查到。你只需要在生成 token 时,把这些 IP 段都填进去。
# 为 GitHub Actions 的 IP 段生成 token
npm token create --read-write --cidr=140.82.112.0/20 --cidr=143.55.64.0/20 --cidr=192.30.252.0/22
注意,GitHub Actions 的 IP 段可能会变化,你需要定期更新。如果你用的是自建 CI(比如 Jenkins 跑在公司的服务器上),那你只需要填公司公网出口 IP 即可。
5.4 不要在代码仓库里提交任何 .npmrc 文件
即使 .npmrc 文件里没有明文 token,只是引用了环境变量,也不建议提交到仓库里。因为如果某个开发者本地环境没有设置对应的环境变量,npm 可能会报错,或者他会随手填一个 token 进去然后误提交。更好的做法是,把 .npmrc 模板放在仓库里,比如 .npmrc.example,然后让 CI 在安装依赖之前,根据环境变量动态生成真正的 .npmrc 文件。
# 在 CI 中动态生成 .npmrc 文件
# 注意:这个命令不应该打印任何内容到日志
cat > .npmrc <<EOF
@your-scope:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
always-auth=true
EOF
在 GitHub Actions 中,你可以把这段命令写在一个 step 的 run 中,并确保 NPM_TOKEN 是通过 env 传入的。下面是一个完整的示例:
- name: Create .npmrc for private packages
run: |
cat > .npmrc <<EOF
@your-scope:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
always-auth=true
EOF
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
注意,这里 NPM_TOKEN 是环境变量名,在 shell 中通过 ${NPM_TOKEN} 引用,而 secrets.NPM_TOKEN 是 GitHub Secrets 中的值。这样生成的 .npmrc 文件只存在于当前 job 的工作目录里,job 结束之后文件会被自动清理,不会留到下一轮。
六、token 泄露了怎么办
不管你怎么小心,都有可能因为意外导致 token 泄露。比如有人在 GitHub issue 里贴了一段日志,里面恰好包含了 npm_ 开头的字符串。这时候不要慌,按照下面的步骤处理:
- 立刻在 npm 官网删除泄露的 token。进入 Access Tokens 页面,找到那个 token,点击删除。
- 如果你不知道是哪个 token 泄露,那就把所有 token 全部删除,然后重新生成。
- 检查泄露的 token 有没有被使用。npm 官方后台可以看到 token 的最近使用时间,如果发现异常使用记录,比如在非你 IP 段的地方调用过,那就要警惕了。同时检查你的 npm 包是否被未授权发布。
- 重新生成新 token,并更新 CI 配置。在 GitHub Secrets 中替换新的值。
- 如果泄露的 token 有发布权限,那么你还需要考虑是否要撤销已经发布的版本。比如攻击者发布了恶意版本,你需要立即将对应版本设置成
deprecated,或者发布一个新版本覆盖掉。
另外,强烈建议开启 npm 的两步验证(2FA)。这样即使有人拿到了你的 token,他没有手机验证码也无法登录你的 npm 账号。npm 官方支持通过 Web 页面开启 2FA,并且你可以设置“发布包时需要 OTP”,这样每次 npm publish 时都需要输入动态验证码。在 CI 环境中,你可能需要把 OTP 也放到 secrets 里,但这样会降低安全性,所以建议不要开启这个选项,而是仅对登录开启 2FA。
七、各方案的优缺点对比
7.1 使用 granular access token 的优缺点
优点:
- 权限可以精确到单个包,不需要给你整个账号的写权限。
- 可以设置过期时间,不用手动定期清理。
- 支持 IP 白名单,增强安全性。
缺点:
- 配置相对复杂,需要理解 scope、权限、过期时间等概念。
- 如果包数量多,你可能需要为每个包都生成一个 token,管理成本上升。
- 某些旧版 npm 客户端可能不支持 granular token,需要升级 npm 版本。
7.2 使用 classic token 的优缺点
优点:
- 配置简单,只需要选一个权限级别。
- 兼容所有 npm 客户端和旧版 CI。
缺点:
- 无法限制包范围,一旦泄露,攻击者可以发布你账号下所有有权限的包。
- 没有 IP 白名单功能,安全性较差。
- 默认不过期,容易忘记清理。
7.3 把 token 放在 CI Secrets 里的优缺点
优点:
- 不会出现在代码仓库中,防止被误提交。
- CI 平台会自动在日志中打码,降低泄露风险。
- 支持多环境、多分支配置,非常灵活。
缺点:
- 依赖 CI 平台的安全性,如果你的 CI 平台账号被攻破,secrets 可能被窃取。
- 在不同平台(GitHub、GitLab、Jenkins)间迁移时,需要重新配置 secrets。
- 需要严格管理谁有权限查看 secrets,不然内部人员也能拿到 token。
八、注意事项和最佳实践汇总
这里把前面提到的内容整理成一份可执行的 checklist,帮助你在配置 CI 时逐一核对。
- 永远不要在代码仓库或 CI 配置文件中明文写 token。哪怕是测试用的临时 token 也不行,因为 Git 历史记录里一旦写入,就很难彻底清除。
- 使用环境变量或 secrets 功能来存储 token。不同 CI 平台有不同的机制,GitHub Actions 使用
${{ secrets.XXX }},GitLab CI 使用$CI_JOB_TOKEN或自定义 variables,Jenkins 使用 credentials binding。 - 拆分 token 权限。发布用读写 token,安装用只读 token,不同 token 分配不同的包权限。千万不要用一个 token 干所有事。
- 设置过期时间。在 npm 生成 token 时,尽量选择 90 天或更短的过期时间。在日历上设置提醒,在过期前一周更新。
- 限制 IP 白名单。只要 CI 出口 IP 稳定,就把 IP 段加到 token 的 cidr 中。
- 动态生成 .npmrc 文件。不要在仓库里放任何包含 token 引用的
.npmrc文件(即使是环境变量引用),因为本地开发者可能误操作。使用 CI 的run命令动态生成,并在 job 结束前删除(实际上 CI 环境是临时容器,job 结束后自动销毁,不需要显式删除)。 - 最小化 token 在 CI 中的使用步骤。能不用 token 的 step 就不要设置
NODE_AUTH_TOKEN环境变量。比如安装公共依赖时,完全不需要认证。 - 监控 token 使用情况。定期使用
npm token list检查 token 列表,删除不用的 token。也可以在 npm 官网查看 token 的最近使用时间和来源 IP。 - 开启账号 2FA。这是保护 npm 账号的最后一道防线,务必开启。
- 定期轮换 token。即使没有泄露迹象,也建议每隔几个月换一次 token。就像定期换密码一样,是良好的安全习惯。
九、总结
npm token 在 CI 环境中的安全认证,说到底就是三个词:生成时限制、存储时加密、使用时最小化。生成 token 的时候,能用 granular access token 就不要用 classic token,能设置过期时间就一定要设置,能加 IP 白名单就加。存储 token 的时候,必须放到 CI 平台的 secrets 里,绝不进仓库。使用 token 的时候,只让它出现在真正需要的步骤里,并且按照用途拆分,发布用读写 token,安装用只读 token。
很多开发者觉得这些步骤麻烦,觉得自己的项目小,不会有人攻击。但安全问题的特点就是:不发生则已,一旦发生,代价往往超出想象。你发布的 npm 包可能被成千上万的项目依赖,如果你不小心把一个有写权限的 token 泄露了,攻击者可以在你的包里植入恶意代码,然后发布一个新版本,所有依赖你包的项目在升级时都会中招。这种供应链攻击已经发生过很多次,造成的损失都是百万美元级别的。
所以,从现在开始,花十分钟检查一下你的 CI 配置,看看里面有没有明文 token,有没有权限过大的 token,有没有永久不过期的 token。如果有,马上按照本文提到的方法改掉。安全不是一劳永逸的事情,而是一个持续改进的过程。每次 CI 构建,其实都是对你安全策略的一次检验。把细节做到位,你的项目才能真正让人放心。
评论
围绕“CI 环境安全认证:npm token 的生成、管理与权限最小化实践”参与讨论