API Token 放在脚本里,容易跟着代码提交;放在 .env 里,又多了一份需要保护的明文文件。对只在自己 Mac 上运行的开发工具,我更愿意把凭据集中存进系统钥匙串,等工具启动时再读取。
macOS 自带的 /usr/bin/security 就能完成这件事,无须另外安装凭据管理工具。不过,保存位置换成 Keychain,并不意味着读取后的凭据自动获得同样的保护。要把这套流程用好,需要同时考虑存储、授权和运行时暴露。
本文以单行 API Token 和密码为例。命令示例采用 Bash/Zsh 可用的写法;交互提示以本机 security help 为准。
1. 先找到正确的帮助入口
很多 CLI 支持 --help,但 security 使用另一套接口。执行 security --help 可能得到 illegal option -- -,这通常只是帮助参数写错了。
security -h
security help
security help add-generic-password
security help find-generic-password
security help delete-generic-password
security 还管理证书、身份和信任设置。保存 API Token 时,主要关注 generic-password 这组命令即可。
这里也要区分三种保护:Keychain 负责凭据存储与访问控制;FileVault 保护磁盘卷;Secure Enclave 提供特定的硬件安全能力。它们有关联,但不能把普通密码条目统一描述成“密钥始终保存在 Secure Enclave 中”。security 的钥匙串操作主要面向文件型钥匙串,和数据保护钥匙串存在实现差异。
2. 用服务名和账户名定位凭据
我建议先约定一个容易检索的命名方式:
Service:
myapp/dev/api_token,表达项目、环境和凭据用途。Account:
dev-admin,表达使用该凭据的账户或身份。
斜杠只是命名约定,不会创建目录,也不会自动形成权限边界。为了避免默认钥匙串或搜索列表变化带来歧义,下面显式指定登录钥匙串:
KEYCHAIN="$HOME/Library/Keychains/login.keychain-db"
这是常见路径;如果使用自定义钥匙串,替换成自己的路径。可以先查看配置:
security default-keychain -d user
security list-keychains -d user
保存与更新:让命令自己询问密码
security add-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
-U \
"$KEYCHAIN" \
-w
这里刻意把不带值的 -w 放在最后。本机帮助明确支持这种交互提示方式,避免把秘密直接写进命令行参数。
-a 指定 Account,-s 指定 Service。-U 允许更新已有匹配条目;首次保存时仍可创建。日常使用应始终携带完整定位条件和同一个钥匙串路径。
如果不指定钥匙串,新增操作使用默认钥匙串;查询操作不指定路径时,则使用默认搜索列表。两者的默认范围并不相同。
读取:标准输出会包含明文
security find-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
-w "$KEYCHAIN"
查询命令的 -w 表示只向标准输出打印密码,不打印元数据。它并不是某个英文短语的缩写说明,也不是脱敏输出开关。
这条命令适合理解接口,但真实凭据不宜直接打印到终端。终端回滚、录屏和日志都可能留下副本。实际脚本中应直接捕获输出,并检查读取是否成功。
删除:清理本地副本
security delete-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
"$KEYCHAIN"
删除 Keychain 条目不会撤销远端 Token。凭据泄漏后,应先在服务端撤销或轮换,再清理本地副本。
3. 按需注入,让秘密少停留一会儿
把读取命令放进 .zshrc 确实方便,但每次启动 Shell 都会执行。导出的变量还会被后续启动的子进程继承,扩大凭据的可见范围。
我更倾向于在 Shell 配置中定义函数,等真正使用时再读取。下图中,蓝色箭头只通向一次工具调用;橙色边界强调,秘密一旦离开钥匙串,就进入进程的运行时范围。
凭据按需进入工具进程,钥匙串保护不会自动延伸到进程环境
以已有的 myapp 命令为例,可以定义一个包装函数:
run_myapp() (
# Keep tracing off while handling secrets.
set +x
keychain="$HOME/Library/Keychains/login.keychain-db"
if token=$(/usr/bin/security find-generic-password \
-a "dev-admin" \
-s "myapp/dev/api_token" \
-w "$keychain"); then
if [ -z "$token" ]; then
printf '%s\n' 'Credential is empty.' >&2
return 1
fi
else
printf '%s\n' 'Cannot read the credential.' >&2
return 1
fi
export MYAPP_API_TOKEN="$token"
unset token
exec myapp "$@"
)
myapp 是示例工具,请替换成实际程序,并确认它支持 MYAPP_API_TOKEN 这样的环境变量接口。
函数体使用圆括号,在子 Shell 中运行。父 Shell 不会因此长期保留新导出的凭据。exec 让工具替换当前子 Shell,工具退出后,这次调用的环境也随之结束;工具自己启动的子进程仍可能继承变量。
这里先赋值,再检查返回状态。不要写成 export TOKEN=$(security ...) 后直接检查 $?:此时拿到的可能是 export 的状态,掩盖了读取失败。local TOKEN=$(...) 也有类似问题。
也不要一开始就添加 2>/dev/null。凭据不存在、访问被拒绝或钥匙串锁定,都需要排查。只消除错误输出,会让脚本带着空变量继续执行。
环境变量仍是明文接口。它可能通过调试、诊断、进程检查或工具日志暴露,具体可见性取决于系统权限和工具行为。按需注入缩短了暴露时间,但不能隔离已经获得同等用户权限的恶意代码。
示例中关闭 set -x 可以避免后续 Shell 跟踪打印变量展开结果;外层脚本、CI 日志和目标工具的调试设置仍需要分别检查。
4. PostgreSQL:-w 的含义完全不同
下面这种写法不能用于传入数据库密码:
# Incorrect: -w does not take a password.
psql -h prod-db.internal -U postgres -d maindb -w "$DB_PASS"
psql -w 的含义是“不要提示输入密码”。后面的字符串不会被当作 -w 的密码参数。
如果接受短时使用环境变量,可以在子 Shell 中读取后调用:
db_connect() (
set +x
keychain="$HOME/Library/Keychains/login.keychain-db"
if password=$(/usr/bin/security find-generic-password \
-a "postgres" -s "infra/prod/postgres" \
-w "$keychain"); then
[ -n "$password" ] || return 1
else
printf '%s\n' 'Cannot read the database credential.' >&2
return 1
fi
export PGPASSWORD="$password"
unset password
exec psql -h prod-db.internal -U postgres -d maindb -w "$@"
)
数据库地址是占位示例。PostgreSQL 官方不推荐用 PGPASSWORD 保存密码,因为部分系统允许其他用户查看进程环境。这里展示的是一种有明确取舍的临时传递方式。
如果环境不接受这种风险,应考虑权限受限的密码文件,或服务端支持的其他认证机制。密码文件又引入了明文落盘问题,需要管理权限、生命周期和备份。具体选择应服从实际威胁模型,不必把“零明文文件”当成压倒其他条件的目标。
5. 隐藏输入、授权弹窗,各自解决什么问题
read -s 只解决输入回显的一部分问题
先用 read -s 读取,再运行 security ... -w "$SECRET_VAL",通常能避免把秘密字面量写入交互历史,但变量展开后的值仍会进入外部进程参数。
因此,本文优先使用 security 自带的交互提示。不要把普通的 echo "$TOKEN" | security ... 当作标准输入接口;-w 的参数语义需要按帮助文档理解。
unset 也只移除变量,不承诺对进程内存进行密码学意义上的安全擦除。Shell 命令替换还会移除输出末尾的换行,因此这套文本方案适合常见单行凭据,不适合任意二进制秘密。
“始终允许”需要结合实际调用程序理解
是否弹出访问提示,取决于条目的访问控制、请求程序和钥匙串状态。不能简单归因于使用了 iTerm2 或 Ghostty。
尤其需要注意:security add-generic-password 的帮助说明,创建条目的应用默认受信任,可无提示访问其数据。创建和读取都使用 /usr/bin/security 时,不应预期每次读取都要求输入密码。
“始终允许”影响的是条目的应用访问授权,不能理解为只授权了当前这一份 Shell 脚本。其他脚本也可能调用同一个受信任程序。
如需移除创建程序的默认信任,可以在创建时明确指定:
security add-generic-password \
-a "dev-admin" \
-s "myapp/dev/approval_token" \
-T "" \
"$KEYCHAIN" \
-w
这是一个单独的条目示例。-T "" 不等于强制每次生物识别;后续提示和授权还需结合实际系统检查。已有条目的 ACL 应在“钥匙串访问”中核对,不要假设一次更新就重置了全部历史授权。
不要为了消除弹窗使用 -A。这个选项允许任何应用无警告访问该条目,扩大了授权范围。
6. 把它用在适合的位置
对于个人 Mac 上的开发工具,Keychain 很适合作为本地凭据存储:系统自带、脚本接口直接,也能减少项目目录里的秘密副本。
跨平台同步、团队共享、审批和集中轮换,则需要额外设计。gopass 一类工具可以结合加密存储和 Git 工作流;更复杂的团队环境可以使用专门的秘密管理服务。Keychain 也存在备份和迁移机制,但不应因此把 security 当成现成的跨平台团队凭据系统。
长期无人值守的 CI 同样需要单独评估。登录钥匙串的解锁和授权提示,可能无法适配没有图形会话的任务。不要为了解锁钥匙串,又把钥匙串密码硬编码进脚本。
我的日常使用原则很简单:凭据在 Keychain 中集中保存;写入时避免秘密出现在参数中;读取失败就停止;只在必要的进程里注入。配合 FileVault、最小权限 Token 和定期轮换,这套方式可以减少常见的明文副本,但运行中的程序依然需要承担保护凭据的责任。

