跳到正文

用 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-key

Lovable 有专门存放机密的地方,当某个集成需要密钥时,会提示你填写,而不是把它写进代码。在 Vercel 或 Netlify 上,请使用不带 VITE_ 或 NEXT_PUBLIC_ 前缀的环境变量,只让服务器函数读取。

3. 从服务器函数调用服务

浏览器调用你的函数,你的函数再用私密密钥调用外部服务。下面是一个用于 AI 功能的最小 Edge Function:

supabase/functions/ask/index.ts
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

本文还有以下语言版本: EnglishPortuguêsEspañol日本語

全部文章