用 AI 开发的应用里有 OpenAI 或 Stripe 密钥?把它们移到服务器函数中
前端里的任何密钥,任何人都能复制。哪些密钥可以放在浏览器中,如何找出不该放的密钥,以及如何把它们移到 Supabase Edge Function。
阅读约 4 分钟
你让 AI 工具给应用加上聊天机器人、支付或邮件功能,结果一次就成功了。之所以这么快,往往是因为 API 密钥被直接写进了前端代码。
前端里的一切都会发送到每位访问者的浏览器。只要密钥在那里,任何人都能打开开发者工具、复制并使用它,用的是你的账户,付的是你的账单。网上有专门在公开网站上搜索这类密钥的自动扫描器。
哪些密钥可以放在浏览器中
并非所有密钥都是机密。很多服务会有意发放两类密钥:
| 可以放在浏览器中 | 绝不能放在浏览器中 |
|---|---|
Stripe 可发布密钥(pk_live_…) |
Stripe 私密密钥或受限密钥(sk_live_…、rk_live_…) |
| Supabase 的 publishable 密钥或 anon 密钥 | Supabase 的 secret 密钥或 service role 密钥(泄露后该怎么办) |
| 统计分析和标签 ID | OpenAI、Anthropic 等 AI 服务的密钥(sk-…) |
| 限定在你域名下使用的地图密钥 | 邮件发送密钥(Resend 的 re_…、SendGrid、Postmark) |
判断标准:如果这个密钥能让别人花钱、读取私密数据,或以你的名义发送内容,它就应该放在服务器上。
密钥泄露的代价取决于服务。AI 密钥可能在几小时内产生巨额账单;Stripe 私密密钥可以读取你的客户信息并发起退款;邮件密钥则会让人以你的域名发送钓鱼邮件,还会在之后几个月里影响你邮件的送达率。
在已上线的应用中查找密钥
打开应用,按 F12,进入 Sources(Chrome)或 Debugger(Firefox),在所有文件中搜索(Ctrl+Shift+F 或 Cmd+Option+F):sk-、sk_live、rk_live、re_、sb_secret_、service_role。
如果代码在本地,就先构建,再搜索构建结果,这正是访问者收到的内容:
npm run build
grep -rEn "sk-[A-Za-z0-9_-]{20,}|sk_live_|rk_live_|re_[A-Za-z0-9_]{20,}|sb_secret_|service_role" dist/常见的来源是环境变量文件。在 Vite 项目中(Lovable 和 Bolt 生成的大多数应用都是),所有以 VITE_ 开头的变量都会被复制进浏览器代码;在 Next.js 中,以 NEXT_PUBLIC_ 开头的变量也是如此。放在 VITE_OPENAI_API_KEY 里的密钥,即使写在 .env 中,也是公开的。
如何修复
1. 先替换密钥
移动代码并不能让泄露的密钥失效。请在服务商的控制台中创建新密钥,并删除旧密钥。即使你认为没人看到过,也要先做这一步。
顺便为 AI 服务设置一个每月消费上限,把可能的灾难变成有上限的损失。
2. 把新密钥保存为服务器端机密
在 Supabase 中,Edge Functions 从运行环境中读取机密。可以在控制台的 Edge Functions → Secrets 中添加,也可以用 CLI:
supabase secrets set OPENAI_API_KEY=sk-your-new-keyLovable 有专门存放机密的地方,当某个集成需要密钥时,会提示你填写,而不是把它写进代码。在 Vercel 或 Netlify 上,请使用不带 VITE_ 或 NEXT_PUBLIC_ 前缀的环境变量,只让服务器函数读取。
3. 从服务器函数调用服务
浏览器调用你的函数,你的函数再用私密密钥调用外部服务。下面是一个用于 AI 功能的最小 Edge Function:
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req) => {
// 1. Only signed-in users may call this function.
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')! // public key: it only identifies the project
);
const token = (req.headers.get('Authorization') ?? '').replace('Bearer ', '');
const { data: { user } } = await supabase.auth.getUser(token);
if (!user) return new Response('Unauthorized', { status: 401 });
// 2. Accept only what the feature needs.
const { question } = await req.json();
if (typeof question !== 'string' || question.length > 2000) {
return new Response('Bad request', { status: 400 });
}
// 3. The secret key never leaves the server.
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: Deno.env.get('OPENAI_MODEL'),
max_tokens: 500,
messages: [{ role: 'user', content: question }]
})
});
const data = await response.json();
return Response.json({ answer: data.choices?.[0]?.message?.content ?? '' });
});在应用中,调用方式变为 supabase.functions.invoke('ask', { body: { question } }),Supabase 客户端会自动附带当前登录用户的令牌。
4. 支付:由服务器决定价格
对于 Stripe,仅仅移动密钥还不够。如果由浏览器告诉你的函数要收多少钱,别人就能修改金额。浏览器只应该发送买什么(商品或方案的 ID);由服务器查询价格,用私密密钥创建 Checkout Session,再返回链接。然后通过经过 Stripe 签名验证的 webhook 确认付款,绝不要依赖用户可以直接打开的"支付成功"页面。
每次发布前
- 在构建结果中搜索上述模式。
- 任何带公开前缀的变量里都不要放机密。
- 当 AI 建议"先暂时"把密钥放在前端时,请让它改用服务器函数。
如果你的应用涉及支付、AI 功能或个人数据,我们免费的 Security Check 会以只读方式从外部查找暴露的密钥、开放的函数和未验证签名的 webhook,并把最严重的问题以书面形式发给你。
API 密钥AI 开发的应用SupabaseBolt
继续阅读
全部文章

应用安全
Supabase Storage 公开存储桶:你的用户文件是否已经暴露?
头像可以公开,身份证件、发票和合同则不行。本文介绍如何检查 Supabase Storage 的存储桶和规则,以及如何在不影响上传的前提下修复。
阅读约 4 分钟

应用安全
在浏览器里发现了 Supabase 的密钥?第一个小时该做什么
前端中的 service role 密钥或 secret 密钥,会让任何人完全掌控你的数据库。正确的顺序是:作废、替换、查看日志,再修复根源。
阅读约 5 分钟