连接快搭好了。智能体拼好了配置,写上了服务名字,就在一个字段上停住了 - API key。服务给了一长串字符,光看这串字符本身看不出该把它放哪儿。放进聊天记录?放进代码旁边的文件?还是直接写进配置里,图个保险?

我们用一个统一的模型来理清这件事:看看值是在哪里设置的,哪个进程拿到它,以及怎样在不暴露秘密本身的前提下确认配置已经生效。不管是本地运行、MCP 连接,还是已发布的应用,模型都是同一个 - 变的只是您在配置谁的环境。

访问密钥能做什么

密钥把一次请求跟一个账号或项目绑在一起,也跟发给您的那些权限绑在一起。服务靠它知道是谁来了、这个来访者能做什么、已经用掉了多少额度。密钥具体意味着什么,由每个服务自己定:有的开放整个账号,有的只给一个项目,还只能读。

说明文档里跟"密钥"这个词经常一起出现的还有令牌(token)。日常表达里两者常被当成一回事,但机制上的细节并不一样:获取方式、有效期、拥有的权限都可能不同。最好别把它们当成完全的同义词 - 服务具体发的是什么,要看它自己的文档。

服务为什么非要把您区分出来,用一个日常的例子最容易看明白。您注册了账号,选了一个每月请求数有上限的套餐,拿到了一个密钥。之后服务就靠这个密钥统计您的请求,跟额度对比,要是套餐是付费的,还会据此结算账单。密钥落到别人手里,就等于把您的额度和账单交给了别人。

请求带着密钥抵达服务,服务找到所有者,检查权限和额度,然后决定放行还是拒绝这次请求对 API 的请求附带密钥服务读取密钥开始检查找到所有者账号或项目检查访问权限和剩余额度做出判断放行还是拒绝
服务按顺序完成这些检查,然后才回应请求

密钥自己不会做判断。它只是让服务能找到所有者的设置,具体这次请求放不放行、要不要从额度里扣掉,都是服务自己决定的。

也有一些值,服务会专门标成公开的 - 这些恰恰是该放进页面代码里的。光看变量名区分不出哪个是公开值、哪个需要保密。文档里写着每个值的用途,该看的是那里。下面的例子里全都用明显的占位符,比如 fc-EXAMPLE-0000000000,真正的密钥不会长这样。

应用可以把秘密放在哪里

设想这样一个场景。您把密钥直接写进了页面代码,跑起项目,拿到了 API 的响应。一切正常。发布之后应用也照样打开,请求也能通过 - 看起来任务已经完成了。

但代码连同密钥一起,也会送到每一位访客的浏览器里。打开开发者工具,在 JavaScript 或者请求里找到它,复制一下就行。之后别人就能以您的名义发请求了。这些请求会消耗您的额度,直到服务开始拒绝您自己的正常请求为止。

已发布页面代码里的密钥进入访客浏览器,被复制走,用来发别人的请求,消耗的是所有者的额度已发布的代码密钥写在里面访客浏览器拿到全部代码密钥被复制已经不是秘密了别人的请求以您的名义发出您的额度在持续减少
一个能用的密钥一旦写进页面代码,就成了每个访客都能用的密钥

问题不在于某个具体的打包工具。页面传给浏览器的所有东西,浏览器的主人都能拿到:HTML、样式、JavaScript、请求的地址和请求头。哪怕打包时密钥是从本地 .env 里取的,一旦被替换进 JavaScript,它就跟其余代码一起送到访客那儿去了。

Backend 是运行在服务器上的代码。访客拿到的是它的响应,而不是源代码,也不是服务器进程的环境变量。所以 backend 可以把密钥留在自己这边,以应用的名义去访问外部服务。

请求的走法变了。浏览器给您的 backend 发一个不带密钥的普通请求。Backend 从自己的环境里读出密钥,加进另一个发给 API 的请求里,再只把结果返回给浏览器。

浏览器给 backend 发普通请求,backend 加上自己环境里的密钥,再去访问外部 API浏览器没有密钥Backend执行请求外部 API检查密钥Backend 的环境密钥存在这里不带密钥带着密钥
秘密只出现在 backend 和外部 API 之间

现在访客看到的是 backend 的地址和响应里的数据,但看不到外部服务的密钥。秘密留在了使用它的那个进程里。

一个秘密可能有两个所有者:

  • 本地进程 - 接了某个集成的 Claude Code、脚本,或者 dev 服务器。它在您的电脑上启动,密钥也是在那儿拿到的;
  • 已发布应用的 backend - 服务器上的代码,自己去访问外部 API。它的环境是在托管平台的面板里配置的。

这是两套互不相干的配置。您电脑环境里的密钥不会自己跑到托管平台上,哪怕服务和变量名完全一样。

把密钥搬到 backend 上,能把它藏起来不让浏览器看到,但公开的 backend 本身还是得防着别人乱发请求。

在 Claude Code 要您填值之前,先让它说清楚两件事:哪个进程会用这个密钥,值又是通过什么方式送到那个进程手里的。要是它答不上来,现在就还没到能配置的时候。

环境变量是怎么工作的

环境变量是程序启动时拿到的一个带名字的值。它有一个名字,比如 FIRECRAWL_API_KEY,还有一个值,就是那一长串字符。配置和代码里写的是名字;值另外存放,不跟代码放在一起。

程序从哪儿拿到它。从终端启动的程序会继承这个终端的环境。您在某个窗口里设置了变量,从那个窗口启动 Claude Code,Claude Code 就能看到它。您打开另一个窗口,在那儿又设了一遍变量 - 这对已经在运行的进程没影响,它的环境是启动那一刻就定下来的。

这正是"密钥明明设了却不生效"这句抱怨最常见的原因。值设在了别的窗口里,或者是启动之后才设的。解决办法是从有这个变量的那个终端重启进程。

学习用的写法,值明显是假的:

# bash:给当前终端设置变量
export FIRECRAWL_API_KEY=fc-EXAMPLE-0000000000
# PowerShell:效果一样
$env:FIRECRAWL_API_KEY = "fc-EXAMPLE-0000000000"

用占位符做学习示例时,这样写没问题。真正的密钥不该用这种方式输入:整行字符串会进入命令历史,关掉终端之后依然留在那里。真实的值应该用不回显的方式输入 - 粘贴字符串,屏幕上什么都不显示,历史里只留下命令本身:

# bash:值以隐藏方式输入
read -s -r FIRECRAWL_API_KEY
export FIRECRAWL_API_KEY
# PowerShell:隐藏输入,再把值解包进环境变量
$secure = Read-Host "FIRECRAWL_API_KEY" -AsSecureString
$env:FIRECRAWL_API_KEY = [System.Net.NetworkCredential]::new('', $secure).Password

检查配置的时候要注意,别让值出现在屏幕、日志或截图里。回答"配置好了没有"这个问题,两行就够:

# bash
[ -n "$FIRECRAWL_API_KEY" ] && echo "已设置" || echo "未设置"
# PowerShell
if ($env:FIRECRAWL_API_KEY) { "已设置" } else { "未设置" }

密钥的长度和开头的字符都不用打印出来。对检查来说这没有任何帮助,而密钥的片段本身已经是秘密的一部分。

设置永久变量的方法有很多,每个系统都不一样。现在没必要一一弄清楚:本地工作有终端就够了,托管平台上则在它自己的面板里设置变量。

环境变量只是一种不把秘密写进代码的办法,不是一种防护手段。值不会被加密,跑在同一个环境里的程序能随意读到它。正因如此,配置的是谁的进程才这么重要。

为什么需要 .env 和 .env.example

每次打开新终端都手动设置变量很快就会让人厌烦,所以人们把值存进一个文本文件 .env 里。里面每一行保存一对名字和值。

最容易漏掉的一个细节是:文件本身什么都不会自动配置好。得有什么东西去读它,再把值分发到进程的环境里 - 应用、库,或者启动您程序的那个 launcher。创建一个 .env 就以为随便哪个程序都会自动读取它,这个想法是不对的。您这里具体是谁在加载这个文件,值得直接去确认,而不是靠猜。

它旁边通常还有另外两个文件:

# .env - 只留在您自己的机器上
FIRECRAWL_API_KEY=fc-EXAMPLE-0000000000
# .env.example - 给打开这个项目的人看的模板
# 值是空的,这里只有名字
FIRECRAWL_API_KEY=
# .gitignore
.env
文件作用是什么里面是什么
.env运行用的本地值真实的名字和值,只在您自己的电脑上
.env.example给打开项目的人一点提示同样的名字,值是空的,有时带一句简短说明
.gitignore让本地文件不进入仓库的规则写着文件名的一行

这样分开的道理很简单。.env.example 回答的是"这个项目需要哪些变量",它可以安心放进仓库,因为里面没有真实的值。哪怕只是临时的,也别把真正的密钥放进这个文件。

要是您还不熟悉 Git,这里只需要记住 .gitignore 的作用就够了:它列出了不该加入仓库的文件。Git 和 .gitignore 的详细用法,我们会在第 13 级再讲。

接下来是一个经常有人栽跟头的 .gitignore 陷阱。这条规则只对 Git 还没开始追踪的文件起作用。如果 .env 曾经被提交过一次,再往 .gitignore 里加一行也解不开这层关系。文件依然处在版本控制之下,它的改动还会继续进入提交记录。Git 的文档里说得很明确:Git 已经在追踪的文件,忽略规则对它不起作用。gitignore 文档。

.gitignore 规则能保护未被追踪的文件,但不会让已经进入 Git 的文件停止被追踪.gitignore 里有一行.envGit 已经在追踪.env 了吗?规则生效.env 留在本地文件仍留在 Git 里得单独把它解绑否是
结果取决于 Git 之前有没有开始追踪这个文件

图中关键的判断点在中间。.gitignore 里同一行文字,只有在 .env 还没被追踪的时候才管用。已经被追踪的文件需要单独再走一步。

所以在往文件里写真正的秘密之前,最好先确认一下 Git 对它一无所知。git status 会显示这个文件是不是出现在改动里。要是文件已经被追踪了,用 git rm --cached .env 把它从追踪里拿掉,再提交这个改动 - 文件还留在磁盘上,但已经脱离了版本控制。

只是要留意,这只影响以后的提交。已经进了历史记录的值还会留在那儿,得用另外的办法处理 - 下面会讲到。

怎样把密钥传给 MCP 连接

MCP 是把外部工具接入 Claude Code 的一种方式。连接写在 .mcp.json 文件里,这个文件通常和项目一起放在仓库里,也就是说整个团队都能看到。所以里面不该出现任何秘密。

通过 HTTP 接入 Firecrawl 服务,最简单的写法是这样:

{
  "mcpServers": {
    "firecrawl": {
      "type": "http",
      "url": "https://mcp.firecrawl.dev/v2/mcp",
      "headers": {
        "Authorization": "Bearer ${FIRECRAWL_API_KEY}"
      }
    }
  }
}

文件里没有密钥。请求头里写的是 ${FIRECRAWL_API_KEY} - 按名字引用一个环境变量。Claude Code 会把它运行所在进程的环境里的值替换进来。进仓库的是名字,值是每个人各自的。Claude Code 能在 url、headers、env、command 和 args 这些字段里做这种替换。Claude Code 关于 MCP 的文档。

值从终端进入进程环境,再被替换进请求头,共享的配置文件里只保存变量名终端里的输入进程的环境对服务的请求.mcp.json 保存的是名字${FIRECRAWL_API_KEY}
值存在于进程的环境里,共享文件里只保存变量名

从这张图能看出,请求头里汇合的是两处不同来源的东西。请求头的模板来自共享配置,值则是从进程的环境里替换进去的。整个过程中,任何一步都没有把密钥写进那个会进仓库的文件。

地址和传递密钥的方式,都是从服务自己的文档里拿的。Firecrawl 用的是 Authorization 这个请求头,文档还特别提醒不要把密钥放进 MCP 地址里。理由很简单:地址是普通文本,它会出现在配置、日志和命令历史里,密钥也会跟着一起进去。

有些服务支持通过浏览器登录:客户端自己打开登录页面,完全不需要手动传 API key。Firecrawl 为此专门准备了一个地址。Firecrawl 关于 MCP 的文档。

要是工具没反应,按下面的顺序检查:

  1. 配置里的变量名和您设置的名字,是不是每个字符都一致;
  2. 值是不是设在了启动 Claude Code 的那个进程里;
  3. 设置变量之后,进程是不是已经重启过。

变量不存在的时候,Claude Code 不会默默地崩掉。它照样加载配置,把 ${FIRECRAWL_API_KEY} 这段文字原样留着不替换,再写一条警告,指出缺的是哪个变量。值本身不会在任何地方被打印出来,所以这样排查是安全的。绝对没必要把整个环境都输出出来去找问题。

这个例子是为了帮您弄懂一个已经搭好的连接。怎么挑选、添加服务器,它又能拿到什么权限,这是另一个话题;这里要弄清楚的只是秘密在这套结构里放在哪儿。

要是密钥已经泄露了

这种情况并不少见。密钥出现在了一条消息、一次提交,或者一张截图里。不用慌,但也别拖着不管。处理顺序是这样的:

  1. 去服务的后台吊销或者更换密钥。这是唯一能让那串旧字符彻底失效的一步;
  2. 把新的值设置到需要的地方:本地、托管平台、连接的配置里;
  3. 处理泄露发生的地方 - 删掉消息、改好文件、关掉访问权限;
  4. 去后台看看这个密钥被怎么用过,服务那边显示的费用又是多少。

顺序就是这样,第一步不能挪后。删掉消息或者在旧提交上面再提交一个新的,都不会让原来泄露的秘密重新变安全。消息可能已经被人读过,仓库可能被克隆过,历史也可能被保存下来了。只要这个密钥还有效,谁拿到它,它对谁都有效。

单独说说仓库的情况。要是秘密混进了某次提交,GitHub 有一份关于删除这类数据的指南。从仓库中删除敏感数据。但要留意,彻底清理历史会重写已经存在的提交,也就是会影响所有跟这个仓库打交道的人。这种操作要跟仓库的所有者商量好,而不是照着一篇学习用的文章自己一个人就动手。换密钥这件事则要立刻做,不用跟任何人商量。

可以把哪些事交给 Claude Code

这篇文章里讲的机制,几乎全都能交给智能体去处理,只有一件事例外:秘密本身要由您自己输入。可以放心交给它做的是:

  • 准备好配置,里面写变量名,而不是值;
  • 说清楚具体是什么在把变量加载进进程,这个变量该出现在哪个进程里;
  • 检查客户端构建产物和 Git 追踪的文件里没有混入秘密;
  • 执行访问检查,汇报结果,但不把值打印出来。

输入真正的密钥这件事留给您自己,通过为此设计的方式:终端里的隐藏输入、本地文件、托管平台的面板。秘密绝不能出现在聊天记录、截图或者智能体的报告里,哪怕只是"就一下下,为了检查"。

可以这样跟它说:

通过 API 接入这个服务。密钥我自己来设置。
从文档里找到地址、方法和传递密钥的方式。
配置里只放环境变量的名字,不要放值。
说清楚哪个进程该拿到这个变量,又是什么在加载它。
检查客户端构建产物和 Git 追踪的文件里没有这个密钥。
检查访问权限时不要输出值或者值的任何一部分。
汇报结果:已配置、未设置,还是访问被拒绝。

最后说说怎么看报告。"变量已设置"和"访问权限正常"是两个不同的结论,不能拿一个顶替另一个。值缺失和服务拒绝访问也是两回事:前一种情况是请求根本没法签名,后一种是服务响应了,但没给权限。要是智能体只说一句"搞定了",追问一下它具体检查了什么。

一条规则顶一长串清单

秘密不发进聊天,不出现在截图上,也不写进会进仓库的文件里。这篇文章里的其他内容,都是在守住这条规则的同时不妨碍干活的具体办法。