
包发出去的那一刻你对账号的掌控其实只取决于一串密码。前年冬天一个做内部工具库的朋友凌晨收到 PyPI 的通知邮件说他维护的包发布了 0.4.3 版本——他当时在睡觉。等早上打开电脑那个版本已经被下载了几百次包里多了一段在构建阶段读取环境变量并往外发请求的代码。事后复盘攻击者的入口一点都不高级他那个 PyPI 账号的密码和某个小论坛的密码完全一样而账号从来没开过双因素认证。这就是今天要聊的事在 PyPI 上开通并设置 2FA。本文会把整个流程拆成四件事——为什么要开、开之前要准备什么、网页端怎么一步步点、开完之后 CI 流水线怎么改顺带把几个我自己踩过的报错场景讲清楚。适合所有往 PyPI 发过包的人尤其是把发布逻辑塞在 GitHub Actions 里跑的那批人。1. PyPI 账号把 2FA 做成硬门槛的底层逻辑1.1 一个包维护者迟早会撞上的风险模型很多人对我的开源包被攻击这件事缺乏实感因为脑子里默认攻击者的目标是那些百万下载量的大项目。真实情况恰恰相反攻击者更偏爱维护者少、下载量中等、但被某些项目间接依赖的包。原因很简单——这类包的作者往往是一个人兼职维护账号安全投入最低而它的下游一旦被渗透收益却一点不少。这就是典型的供应链攻击思路不直接打目标而是打目标的依赖。更麻烦的是PyPI 上的发布行为不可撤回地作用于全世界。你在本地写错代码最多是自己机器上跑不起来你往 PyPI 推一个带问题的版本几分钟内就会被各路镜像同步然后被 CI 缓存、被容器镜像固化。即使你立刻 yank 掉那个版本已经装过的环境也不会自动回退。所以 PyPI 的账号安全和普通的论坛账号完全不是一个量级的问题前者是发出去就收不回来后者顶多是被发几条广告。密码泄露的途径也比想象中多。除了撞库还有几种常见情况在别人的机器上登录过 PyPI 网页、用过浏览器同步密码、在某个帮你自动打包发布的第三方服务里填过账号密码。只要密码泄露一次攻击者不需要任何额外条件就能上传新版本。而加上 2FA 之后攻击者即使拿到密码也还缺一个只存在于你手机上、30 秒就失效的动态码。这个门槛不高但足以把绝大多数自动化撞库挡在门外。1.2 PyPI 的 2FA 到底保护了哪些入口这里有个特别容易误解的点2FA 保护的是登录和敏感操作而不是每一次上传。很多人在开通之前会担心那我 CI 里 twine upload 是不是每次都要输验证码不用。PyPI 的设计是用 API token 上传时走的是 token 校验通道不需要动态口令但你要去创建、查看、删除这个 token就需要先完成一次 2FA 验证。我把 PyPI 上会触发 2FA 的操作大致分了三类你对照一下就明白这个机制的边界在哪里操作类型具体例子是否需要 2FA身份验证类网页端账号登录需要账号与权限管理类创建/删除 API token、添加或移除项目的 Collaborator、修改邮箱、删除项目需要且通常会要求重新验证一次上传发布类twine upload、uv publish带 token 上传不需要token 本身就是凭证看懂这张表后面的很多决策就顺了。你不需要为了自动化去想办法绕过2FA因为自动化本来就不走 2FA 那条路你需要做的是把 token 管好把它的作用域收窄。反过来说如果你发现某篇文章教你把动态口令密钥存进 CI 的环境变量里那基本可以判定作者没搞明白这套机制——那样做不仅没意义还等于把 2FA 的保护整个作废了。1.3 三种验证方式的能力边界对比PyPI 目前给你提供了几种不同的第二因子它们在安全强度和可用性上差异挺大选错会在关键时刻给你添麻烦。验证方式典型载体抗钓鱼能力离线可用性适合谁TOTP 动态口令认证器 App手机或桌面端一般验证码可被实时转发好靠本地算法算绝大多数个人开发者默认选它WebAuthn 安全密钥硬件 Key、系统生物识别强凭证与访问域名绑定需要设备在场维护热门包、团队核心账号恢复码打印或存放于离线文件弱属于静态口令好所有账号都必须配一份TOTP 是最实用的起点因为它的成本几乎为零装个认证器 App扫个码就完事。但它有个天然弱点——动态码是可以转述的。如果有人在钓鱼页面诱导你输入当前验证码他在几十秒内提交依然能通过。WebAuthn 就没有这个问题因为签名过程跟域名绑定钓鱼站拿不到有效签名。所以我的建议是先用 TOTP 把账号保护起来如果你维护的包在企业环境里被大量依赖再补一把硬件密钥用 WebAuthn 作为主验证方式TOTP 退居备份。至于恢复码它不是可选项。恢复码是一组一次性使用的静态字符串专门用于你丢失手机、换设备、认证器数据清空之后找回账号。后面我会单独讲它的保存方法因为这一步是整个流程里最容易被人随手划过去的也是最容易在半年后要命的。2. 动手之前必须做完的三项准备2.1 先确认账号状态与你的实际权限范围在点任何按钮之前先登录 PyPI 网页端进到账号设置页面把三件事确认清楚。第一账号绑定的邮箱是不是你能长期访问的。2FA 重置、异常登录提醒、密码找回全走这个邮箱如果它是个早就废弃的邮箱地址那你现在真正该做的是先改邮箱而不是急着开 2FA。第二你在哪些项目里是 Owner 或 Maintainer。PyPI 的项目权限分 Owner 和 Maintainer 两档Owner 才能删项目和改维护者名单。这里要特别提醒一点项目级权限和账号级安全是两回事你被别人加进某个项目当 Maintainer并不意味着你要用他的账号做事每个维护者永远用自己的账号登录。第三确认一下你是否有多个 PyPI 账号。这是很多人遗留下来的历史问题早期用邮箱 A 注册发了一个包后来用邮箱 B 注册发了另一个包时间长了就忘了哪个是哪个。开通 2FA 之前最好把账号梳理清楚不要出现给不常用的账号开了 2FA结果常用的那个还是裸奔这种尴尬局面。梳理的方法很简单用pip index或者直接到项目页面的 Release history 里看每个版本的发布者信息虽然 PyPI 不直接显示发布者账号但你可以从自己记得的发布记录反推。顺便说一句如果你的账号曾经在第三方平台填过密码或者你实在记不清密码有没有复用最稳妥的流程是先在 PyPI 上改一次密码再开 2FA。顺序很重要先改密码再开 2FA可以避免开着 2FA 改密码时触发重新验证的来回折腾。2.2 认证器 App 的选型以及时间不同步这个隐形坑TOTP 的本质是一个基于共享密钥和当前时间的哈希计算服务端和你的 App 各持同一把密钥各自用当前时间戳算出 6 位数字两边一致就通过。这意味着它对时间准确性极度敏感。绝大多数验证码输入无效的问题根子都在这里。选认证器 App 的时候我会看三个维度。一是能否导出备份有些 App 的数据锁在自家云里换手机时迁移很麻烦一旦迁移失败就得走恢复码流程二是是否有桌面端或浏览器插件PyPI 这种要在电脑上操作的场景手机上翻验证码再手敲体验很差三是能否给条目加备注你可能同时有 GitHub、PyPI、npm 好几个账号不写清楚来源半年后打开 App 就是一堆 6 位数。时间同步的具体操作iOS 上的认证器通常在系统日期与时间里默认开着自动设置一般不会有问题Android 个别机型在长时间关机或者省电模式下会产生秒级漂移可以在 App 的设置里找到时间校正或同步之类的选项手动校一次。桌面端的话直接确认系统 NTP 服务在跑就行。判断自己有没有漂移最直观的方法是把 App 里生成的验证码和另一个可信来源对比一下如果两边算出来的数字对不上那基本就是时间问题了。还有一个选型上的坑不要把 TOTP 密钥只存在一个地方。如果你用的是密码管理器自带的 TOTP 功能好处是密钥跟着密码库一起备份坏处是密码和动态码放在同一个篮子里一旦密码库被攻破第二因子就同时失效了。我个人习惯是主用独立的认证器 App恢复码单独打印一份两套东西物理隔离。2.3 恢复码整个流程里最容易被随手划过去的一步恢复码在 PyPI 上的位置很微妙——它是在你设置 2FA 的过程中一并生成的很多人急着完成设置看到一串字符就直接点我已保存其实根本没存。等到半年后手机丢了、认证器数据被系统清理了才发现自己什么都拿不出来。正确的做法是生成之后立刻做三件事。第一把恢复码复制到本地一个纯文本文件里命名为能一眼看懂的名字比如pypi-recovery-codes-2025.txt而不是新建文本文档.txt第二把它放进你自己的加密存储或者离线介质里比如加密压缩包、加密笔记、U 盘第三有条件的话打印一份和身份证、银行卡这类重要纸张放一起。听起来有点夸张但恢复码这东西的性质跟银行 U 盾的备用码一模一样平时用不上用上的时候就是唯一的救命绳。需要特别注意的是PyPI 的恢复码是一次性的用掉一个少一个而且重新生成会让旧的全部作废。所以如果你用掉了一个记得在管理页面里把剩下的重新抄一遍保持备份和线上状态一致。我见过有人用掉两个恢复码之后手里那份还是最早的旧版本剩下六个全是失效的等于白存。3. 在 PyPI 网页端走完一遍完整开通流程3.1 找到 Two factor authentication 入口登录 PyPI 之后右上角头像旁边有个下拉菜单进入 Account settings。这个页面是一条很长的纵向列表从上到下依次是账号基本信息、邮箱、密码、然后是Two factor authentication区块。它的标题下面会有当前状态提示没开过的话会明确写着尚未启用并给出几个并列的按钮大致分为用认证器应用添加添加安全设备生成恢复码这几种。顺序上我建议这样走先生成恢复码再添加认证器最后按需添加安全设备。为什么把恢复码放第一步因为这样万一添加认证器的过程中网络断了、页面卡了、验证码一直过不去你手里已经有恢复码兜底不会出现认证器没配好、账号又被要求验证的两难。虽然这种情况概率不高但既然成本只是先点一下按钮没必要赌。另外提醒一点这一步开始之后PyPI 可能会要求你重新输入密码验证身份。这是正常的会话提升行为不是出问题了。如果你用的是共享电脑或者借用别人的设备操作最好把这一步留到自己的机器上做。3.2 扫码还是手动输入密钥点用认证器应用添加之后页面会展示一个二维码旁边通常还有一段可点击展开的文本密钥。两者的关系是二维码里编码的内容就是那段文本密钥本质上完全一样只是二维码省去了手输的麻烦。选择依据就两条。如果你是在手机上的认证器 App 里添加那就直接扫码最快最不容易错。如果你是在桌面端认证器或者密码管理器里添加也就是接收方和显示方在同一台设备上你没法用同一台设备扫自己屏幕上的码那就必须用文本密钥手动输入。手动输入时要特别注意两点一是密钥里通常包含大小写字母和数字27之类容易混淆的字符看不清就放大页面二是很多客户端会要求你选择算法、位数和周期默认的 SHA1 / 6 位 / 30 秒就是对的不要改。改了之后两边算法不一致验证码永远对不上而且这个错误现象和时间不同步一模一样非常难排查。这一步还有一个容易被忽略的细节密钥只在这个页面显示一次。如果你此刻不方便完成后续验证又不想重新走一遍流程那就把密钥先记下来记到加密笔记里但要清楚这样做的风险——密钥本身就是生成验证码的能力等同于第二因子本体存得越随意越危险。3.3 首次验证与恢复码落盘的实际操作输入 App 里当前显示的 6 位数字提交。通过之后PyPI 会把 2FA 状态切换成已启用并在页面上提示你保存恢复码。如果这里提示验证码无效先别急着怀疑人生按下面这个顺序排查先确认 App 里那条记录对应的是 PyPI不是别的站再看手机时间是不是自动同步再确认自己填的是最新一轮的验证码而不是上一轮残留的。TOTP 的验证码每 30 秒变一次PyPI 一般会容忍相邻时间窗口的轻微偏移但如果你在倒计时只剩一两秒的时候抄下来、走完页面加载再提交很容易刚好跨过窗口边界。恢复码落盘的时候我强烈建议用截图 文本 打印三重备份而不是只点一下页面上的复制按钮。原因很实在复制到剪贴板之后很多人就顺手粘到某个聊天窗口里去了而聊天记录是会长期留存的。恢复码等于半个账号凭证粘到聊天软件里相当于把它交给了平台的服务器。落盘完成后回到 2FA 管理页面确认状态。一个健康的 2FA 配置页面上你应该能看到至少一条认证器条目、一份恢复码记录以及启用时间。如果只看得到认证器看不到恢复码条目说明恢复码这一步没完成回去补上。3.4 再挂一把安全密钥给主认证方式买保险如果你的账号维护着下载量不低、或者在内部被广泛依赖的包我建议再补一把 WebAuthn 安全密钥。它的添加流程和普通网站注册硬件 Key 差不多点添加安全设备按浏览器提示插入硬件 Key、触碰感应区或者直接用系统自带的生物识别比如电脑的指纹模块。整个过程不需要手动输入任何数字比 TOTP 顺畅得多。配好之后有一个关键选择把哪个设成主验证方式。PyPI 允许你在登录时选择使用哪种方式如果你有硬件 Key就优先用它。理由是硬件 Key 的抗钓鱼能力远高于 TOTP而且在真实登录场景下反而更快——插上、碰一下就完了。TOTP 留着当备用防止 Key 忘在公司抽屉里。这里要提醒一个反直觉的点安全密钥越安全越需要配一把备用 Key。因为如果只有一把而它丢了或者坏了你就只剩恢复码这条路可以走。备用 Key 不需要随身带锁在抽屉里就行但在注册的时候一定要一起挂上别等主 Key 出问题才想起来。4. 开了 2FA 之后CI 流水线怎么改4.1 为什么不要把 TOTP 密钥塞进流水线我先把这个错误做法的完整链条讲清楚你就会明白它为什么不能碰。有些教程的思路是既然 PyPI 要 2FA那我用脚本定时生成动态码不就行了于是就出现了把 TOTP 密钥写进 GitHub Secrets、用pyotp之类库在 CI 里算验证码、再模拟网页登录上传的方案。这个方案有三重问题。第一技术上就走不通PyPI 已经不再支持用用户名密码上传网页登录还会涉及会话 Cookie、CSRF 校验硬模拟的脚本极其脆弱PyPI 前端一改就挂。第二安全上完全反了你为了自动化把第二因子固化成一个静态字符串等于把 2FA 降级成一个第二个密码而且这个密码还长期躺在 CI 平台里。第三运维上是自找麻烦一旦密钥轮换所有仓库都得改一遍。正确的方向不是让流水线能过 2FA而是让流水线走不需要 2FA 的通道。PyPI 早就为此准备好了两套方案API token 和 Trusted Publishing。理解了这一点整个 CI 改造其实只有半小时的工作量。4.2 API token 的作用域划分与轮换习惯API token 是传统做法也是最通用的做法。它的核心设计是token 不是账号密码所以它不能用来登录网页、不能改邮箱、不能删项目只能用来上传。这就把风险限制在了攻击者拿到 token 后能往指定项目推版本这个范围内而不是整个账号失守。创建 token 的时候PyPI 会让你选作用域。我强烈建议按项目创建而不是创建账号级 token。区别在于账号级 token 能往你名下所有项目推包一旦泄露波及面是全部项目级 token 只对单个项目有效泄露了也只影响那一个包。如果你维护三个包就建三个 token分别放进对应的仓库 Secret 里。token 落到本地开发环境时配置方式我一般这样写[distutils] index-servers pypi [pypi] username __token__ password pypi-替换成你自己的token注意username必须填__token__这个固定值很多人在这里填自己的用户名结果报认证失败然后去怀疑 token 复制错了。另外这个文件是~/.pypirc默认权限比较宽松别把它提交到 Git 仓库里如果你的项目里恰好有个同名的模板文件记得加到.gitignore。上传命令就是常规的twine upload dist/*。如果你用的是较新的工具链uv publish也支持通过环境变量传 token本质一样。关于轮换我给一个实操建议不要设一个半年后提醒我换 token的日历事件而是按事件驱动轮换。比如某个 token 曾经在本地~/.pypirc里明文存过、或者某个成员离职了、或者你怀疑某台机器被动过那就立刻删掉旧 token 重建。日常状态下不折腾风险事件一发生就换这比机械地定期轮换更现实。4.3 Trusted Publishing彻底摆脱长期密钥如果你的发布是通过 GitHub Actions 走的那么最好的方案是 Trusted Publishing。它的原理是让 GitHub Actions 在运行时生成一个短期的身份令牌PyPI 校验这个令牌里的仓库、工作流、环境信息是否和你在 PyPI 上预先登记的发布者配置一致一致就直接放行。整个过程里没有任何长期密钥存在也就不存在token 泄露这个风险类别。PyPI 侧的配置需要填四项信息仓库所有者、仓库名、工作流文件名、以及可选的环境名。这四项必须和实际流水线严格对上差一个字符都会被拒。GitHub Actions 侧的写法则大致长这样name: Publish on: release: types: [published] permissions: id-token: write jobs: build-and-publish: runs-on: ubuntu-latest environment: release steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - run: python -m build - uses: pypa/gh-action-pypi-publishrelease/v1这里permissions: id-token: write是必须的少了它就拿不到身份令牌报错信息通常比较含糊容易让人往别处找原因。另外工作流文件名那栏要填带.yml后缀的完整文件名而不是工作流显示在页面上的名字这两个经常不一致是最常见的配置错误。Trusted Publishing 和 API token 不是互斥的我个人的做法是主发布路径用 Trusted Publishing同时保留一个项目级 API token 放在加密存储里专门用于极端情况下的手动上传。这样既享受了无密钥的干净也留了一条人工兜底的路。4.4 手机丢了之后账号还能怎么救回来这是必须提前演练一次的流程因为真发生的时候人是慌的。按严重程度从小到大第一种换了手机但旧手机还在。那就直接在旧手机上导出认证器数据或者手工把密钥迁移到新设备最省事。第二种旧手机彻底没了或者被重置了但你手上有恢复码。登录 PyPI 时会看到使用恢复码的选项输入一个未使用过的恢复码就能进入账号管理然后重新绑定认证器。用完记得回到管理页面把剩余恢复码重新抄一遍。第三种认证器和恢复码都没了。这时候只能通过 PyPI 的官方支持渠道提交账号恢复申请需要证明你是这个邮箱和项目的合法拥有者。这类流程通常需要几天时间而且不保证一定成功。从这三种情况能得出一个很实在的结论恢复码的价值不在于它多安全而在于它是唯一一条不依赖第三方人工介入的自助通道。你在配置 2FA 的时候多花两分钟把它存好等于把一个可能耗费几天的找回过程压缩成两分钟。5. 开通与使用中最常撞见的几个报错场景5.1 验证码反复提示无效的四种可能按我遇到的频率从高到低排时间漂移。App 所在设备的时间和真实时间差了几十秒。这种问题的特征是偶尔能过、偶尔不能过很符合概率分布。扫错密钥。同一台手机上添加了多个站点的记录PyPI 那条扫描时扫串了或者手动输入时少输了一位字符。算法参数被改过。手动输入密钥时客户端默认给的是 SHA1 / 6 位 / 30 秒如果被改成了 8 位或者 SHA256算出来的数字形态都不一样。验证码已被使用。TOTP 是一次性有效如果浏览器自动填充了你上一次输入的值提交时其实是在重放旧码。排查顺序建议从时间开始因为它是唯一一个你看不出来但影响最大的因素。确认时间没问题之后直接把认证器里那条 PyPI 记录删掉重新添加一次比逐项排查便宜得多。5.2 恢复码用完之后才发现没有备份这种情况我见过好几次处理路径取决于还剩什么。如果你还剩至少一个可用的恢复码那就用它登录进去然后立刻做两件事重新生成一组新的恢复码旧的会全部作废并且这次真的存好。如果你已经用完了所有恢复码但认证器还能用也还有救——直接用认证器登录在管理页面重新生成一组。真正无解的情况是恢复码用完了 认证器也没了。这时候除了走官方支持渠道没有别的办法而且大概率需要你能证明邮箱所有权和项目归属。所以我在前面反复强调恢复码要物理备份不是多余的谨慎是因为这条路的终点真的很麻烦。5.3 多人协作的项目2FA 应该怎么分摊先给结论不要共用账号。有些小团队习惯用一个公共账号发所有包理由是方便管理。这在没有 2FA 的年代勉强能凑合开了 2FA 之后会立刻变成一个死结——动态码在谁的手机上谁负责操作有人离职怎么办把恢复码贴在团队文档里那 2FA 就白开了。正确做法是每个成员用自己的 PyPI 账号然后在项目的 Collaborators 页面把人以 Owner 或 Maintainer 的身份加进来。每个账号各自开自己的 2FA各自管自己的恢复码。有人离职从 Collaborators 里移除即可不影响其他人发布。这个模式比共用账号多花几分钟配置但它把人的变动和账号的安全彻底解耦了长期看省的是大麻烦。如果你们的发布确实需要集中管理比如希望通过流水线统一控制谁能触发发布那就用上一节讲的 Trusted Publishing 配环境变量审批。让权限落在 GitHub 的 Environment 配置上而不是落在一个人人可用的共享账号上。最后分享一个我自己一直在用的小习惯开完 2FA 的当天我会故意走一遍用恢复码登录的完整流程——不是为了真的用掉它而是为了确认备份里的那串码确实能打开账号。很多人把恢复码存了但从来没验证过等到真出事才发现自己当年抄错了一位或者存的是重新生成之前的旧版本。花五分钟做一次这样的演练比任何安全建议都实在。另一个细节是把 2FA 的启用日期和恢复码的存放位置记在密码管理器的一条备注里一年后再来看你会感谢当时留下这条线索的自己。