密钥管理:别再把密钥写进代码
API key、数据库密码、JWT secret——硬编码进代码 = 定时炸弹。本文讲清密钥管理的正确姿势:.env(开发)→ 环境变量(Docker)→ 密钥管理服务(HashiCorp Vault / AWS Secrets Manager 生产),轮换与审计,以及密钥泄露后的应急。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
一、硬编码密钥的灾难
GitHub 公开仓库里每天新增的泄露密钥数以万计。一个新员工把公司代码 push 到自己的公开仓库"备份一下",一个不小心的 git log 截图发到群里,一次 CI 日志没脱敏直接输出——任何一次都足以让 AWS 账单一夜飙到五位数,让数据库被拖库,让 OpenAI key 被拿去刷量。
这不是小概率事件。GitKraken、Codecov、三星、Toyota 都因为密钥泄露上过头条。真正的灾难不是密钥泄露本身,而是密钥和代码绑在一起:仓库一旦泄露,所有密钥同时泄露,而且因为 Git 历史的存在,即使你删了那一行,密钥仍然躺在每一个 commit、每一个 tag、每一个 fork 里。
第一条原则,也是唯一不可妥协的一条:密钥必须和代码分离。代码仓库是给人看的、给 CI 跑的、会被 fork 会被镜像——它天然是公开的,即使现在是 private 的。把密钥当一等公民从代码里剥离出去,是后面所有方案的前提。
下面按"开发 → 容器 → 生产"三层,讲清楚每一层该用什么、不该用什么。
二、层级一:.env + .gitignore(开发够用,别进生产)
本地开发最务实的方案就是 .env 文件 + dotenv。简单、通用、所有语言都有库。
# .env —— 永远加进 .gitignore
DATABASE_URL=postgresql://localhost:5432/devdb
JWT_SECRET=dev-only-not-for-production
STRIPE_SECRET_KEY=sk_test_xxx
# .gitignore
.env
.env.local
.env.*.local
但 .env 有两个陷阱必须配套解决:
第一,提交一份 .env.example 做模板。 它列清楚项目需要哪些变量、什么格式,但只放占位符不放真实值。新人 clone 下来 cp .env.example .env 就知道要填什么。这是协作的刚需,不是可选项。
# .env.example —— 进版本控制的模板
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=
STRIPE_SECRET_KEY=
第二,.env 只能停在开发。 把 .env 带进生产等于把生产密钥明文写进了部署机器的文件系统:谁登上去都能 cat,机器被攻破密钥就跟着丢,而且没法审计谁取过、没法轮换。生产环境请直接跳到第三、四层。
另外,在 .gitignore 里加上 .env 之后,养成一个习惯:git status 看一眼,确认 .env 没出现在待提交列表里。再加一层保险——用 git-secrets 或 trufflehog 做 pre-commit 扫描,在你真把密钥提交进去之前拦住。
三、层级二:环境变量(Docker / K8s Secret)
进入容器化部署,密钥的载体从文件变成了环境变量。原则不变:密钥不进镜像。
Docker 镜像是可被任意拉取、缓存、分发的制品。把 ENV DATABASE_URL=... 写进 Dockerfile,等于把生产密钥打包进了一个会被推到 registry、被开发者本地 pull 的东西。正确做法是构建时用占位符,运行时注入:
# Dockerfile —— 只声明,不赋真实值
ENV DATABASE_URL=""
CMD ["sh", "-c", "node server.js"]
# 运行时通过 -e 或 --env-file 注入
docker run -e DATABASE_URL=$DATABASE_URL -e JWT_SECRET=$JWT_SECRET myapp:latest
到了 K8s,用 Secret 而不是 ConfigMap 存敏感数据,然后以环境变量或挂载文件的方式给 Pod:
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DATABASE_URL: postgresql://prod-db:5432/app
JWT_SECRET: <从外部注入,别写进 YAML>
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
注意:K8s Secret 默认只是 base64 编码,不是加密——能 kubectl get secret -o yaml 的人就能解码。生产环境务必开启 encryption at rest(etcd 加密)和 RBAC 限制(只有特定 ServiceAccount 能读特定 Secret),或者干脆跳到下一层,用外部密钥管理服务通过 CSI Driver 挂载,让密钥根本不进集群 etcd。
CI/CD 这条线同理:密钥放 GitHub Actions Secrets / GitLab CI Variables,在 workflow 里通过 ${{ secrets.X }} 引用,构建时注入到环境变量,绝不写进仓库 YAML。
四、层级三:密钥管理服务(Vault / Secrets Manager / Doppler)
生产环境真正该用的,是专门的密钥管理服务(Secrets Management Service)。核心能力四个:集中存、动态取、可审计、能轮换。代表选手:
- HashiCorp Vault —— 开源、可自建、最强大,支持动态密钥(按需向数据库申请临时账号)、丰富的 secret engine 和身份认证后端。
- AWS Secrets Manager / Parameter Store —— 托管、和 IAM/EC2/ECS/Lambda 深度集成,按存储和 API 调用计费。
- Doppler / 1Password Secrets Automation —— 开发者体验最好,跨环境(dev/staging/prod)统一管理,一行命令拉取。
以 Vault 为例,应用启动时不持有任何长期密钥,而是用机器身份(K8s ServiceAccount、AWS IAM、GCP IAM)向 Vault 认证,换取一个短期 token,再按需读取:
# 应用机器上,用 K8s 身份登录 Vault
vault write auth/kubernetes/login role=my-app \
jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# 读取数据库凭据 —— Vault 当场向数据库创建一个临时账号
vault read database/creds/my-app-role
# => username: v-root-my-app-abc123 password: ... lease_duration: 1h
这种**动态密钥(dynamic secrets)**是 Vault 的杀手锏:密钥不是预先存在的,而是按需生成、用完即焚、自带过期时间。就算被偷,一小时后也是废纸一张。这是"轮换"理念最彻底的实现。
如果暂时上不了 Vault,用 AWS Secrets Manager 也比硬编码强一百倍——应用代码里只写 SDK 调用,真实值存在 AWS 那边,带版本、带轮换、带 CloudTrail 审计:
import boto3
client = boto3.client("secretsmanager")
resp = client.get_secret_value(SecretId="prod/myapp/db")
db_url = resp["SecretString"]
五、轮换:别让一把密钥用一辈子
密钥是有寿命的。一把数据库密码用三年,这三年里经手过的开发者、运维、外包、离职人员,任何一个环节出问题都不可挽回。轮换(Rotation)是密钥管理的第二支柱,和"分离"一样重要。
轮换分两种:
- 定期轮换:每 30/90 天换一次,即使没出事。把轮换写进流程,而不是"出事了再换"。
- 事件驱动轮换:有人离职、有 commit 可疑、有未授权访问告警——立即轮换相关密钥,别等。
手动轮换是反模式(谁来记得?谁来执行?会不会漏?),自动化才是正解:
- Vault 动态密钥:天然按小时轮换,无需人工介入。
- AWS Secrets Manager:内置 Lambda 轮换函数,一键开启定期轮换。
- 数据库密码:用蓝绿双账号策略(同时维护
user_v1和user_v2,切换连接串后下线旧账号),避免轮换期间的服务中断。
衡量一个团队密钥管理成熟度的最快指标:问他们"现在轮换一次数据库密码要多久"。如果答案是"一天搞定、需要停机",那是初级阶段;"五分钟、零停机、自动化"才是及格线。
六、审计与最小权限
密钥管理服务的第三个核心能力是审计:谁、在何时、取了哪个密钥。Vault 默认开启 audit log,所有读写请求落盘成 JSON,可被 SIEM(如 Splunk、Elastic Security)消费;AWS Secrets Manager 的每次访问都进 CloudTrail。
# Vault audit log 示例(节选)
{"type":"request","path":"secret/data/prod/db","auth":{"display_name":"my-app"}}
{"type":"response","path":"secret/data/prod/db","operation":"read"}
审计的前提是最小权限(least privilege)。每个服务只给它真正需要的那几个密钥,而不是图省事给一个 wildcard。在 Vault 里用 policy 精确控制:
# my-app 这个服务只能读 prod/db 和 prod/stripe,其他一律拒绝
path "secret/data/prod/db" { capabilities = ["read"] }
path "secret/data/prod/stripe" { capabilities = ["read"] }
path "secret/data/*" { capabilities = ["deny"] }
最小权限不只是防御外部攻击者,更是防御内部威胁——那个被钓鱼的实习生、那个要离职的老员工、那个被攻陷的边缘节点。 blast radius 永远要尽可能小。
七、前端的"密钥"幻觉
一句话:没有"前端密钥"这种东西。浏览器里的所有内容——JS bundle、网络请求、localStorage、环境变量——用户都能看到,F12 一按全暴露。
Next.js 的 NEXT_PUBLIC_ 前缀经常被误解。它不是"安全的公开配置",而是明确告知"这会被打包进客户端代码,所以它本身就不是秘密"。
# 这个会被打进 bundle,用户能看到 —— 只能放公开配置
NEXT_PUBLIC_GA_ID=G-XXXXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
# 这个只在服务端,不会泄露 —— 真正的密钥放这里
STRIPE_SECRET_KEY=sk_live_xxx
DATABASE_URL=...
判断标准很简单:这个值被滥用会不会造成损失? Google Analytics ID 不会,公钥不会——可以放前端;能扣钱的 key、能查数据库的凭据、能签 JWT 的 secret——一律只能在服务端。所有需要"鉴权"的操作,都应该是前端调你的后端 API,后端再带着密钥去调第三方。前端永远只持有"身份标识",不持有"权限凭据"。
八、泄露应急:filter-repo + 立即轮换
万一真的发现密钥进了仓库,按这个顺序处理,不要先想着删那一行——删了也没用,Git 历史里还在。
第一步,立即轮换所有可能泄露的密钥。 这一步优先于清理历史,因为只要密钥还在生效,攻击者随时能用。数据库密码、API key、JWT secret、SSH key——全部换掉,同时撤销旧密钥。
第二步,清理 Git 历史。 用 git filter-repo(官方推荐的 filter-branch 替代品,快且安全):
# 安装
pip install git-filter-repo
# 新建一个文件列出要清除的字符串
echo "sk_live_old_key_value" >> secrets-to-remove.txt
echo "postgresql://old:password@" >> secrets-to-remove.txt
# 重写所有历史
git filter-repo --replace-text secrets-to-remove.txt
# 强推到所有 remote(包括 fork)
git push origin --force --all
git push origin --force --tags
注意:这会改写所有 commit hash,所有基于旧历史的 fork、PR、本地仓库都得重新对齐。而且如果仓库是 public 的,应当假设历史已经被爬走了——清理只是善后,轮换才是真正的止损。
第三步,审计未授权使用。 登录云厂商控制台看 API 调用记录(CloudTrail、Audit Log),数据库看异常查询,第三方服务(Stripe、OpenAI)看账单和调用量。如果发现异常,按安全事件流程上报,而不是悄悄处理。
小结
- 密钥必须和代码分离——代码天然是公开的,即使现在是 private 的。
- 三层递进:
.env管开发、环境变量/K8s Secret 管容器、Vault/Secrets Manager 管生产。生产环境上密钥管理服务不是"高级选项",是基本盘。 - 轮换和审计是密钥管理的另一半:别让一把密钥用一辈子,别让任何服务都能读所有密钥。
- 没有前端密钥:
NEXT_PUBLIC_是公开配置,不是秘密;能扣钱的凭据一律留在服务端。 - 泄露应急的顺序:先轮换,再清历史,最后审计。删那一行是没用的——Git 历史永不忘记。
金句送你:密钥一旦进过 Git,就当它已经泄露——立即轮换,而不是赌没人看到。 安全这件事,从来都是按最坏情况准备,而不是按侥幸心理设计。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。