API Key 安全管理与前端调用的最佳实践方案

a'ゞ蒙蒙 安全 阅读 2,538
赞 8 收藏
二维码
手机扫码查看
反馈

API Key 被扫出来了,我连夜改了三遍配置

今天早上运维同学甩过来一条 Slack 消息:“你那个前端项目里,jztheme.com 的 API Key 在 Chrome DevTools 里明文可见,刚被爬虫扫到了。” 我手一抖差点把咖啡泼在键盘上——这玩意儿不是早就配成环境变量了吗?怎么还能裸奔?

API Key 安全管理与前端调用的最佳实践方案

先说结论:问题出在我自己写的 fetch 封装函数里,硬编码了 Authorization: Bearer xxxxx,而且没做任何运行时校验。更离谱的是,这个 key 居然还出现在一个用于本地 mock 的 JSON 文件里(对,就是那种写完就忘删的 mock-data.json),连 .gitignore 都没加进去……

后来查 Git 历史发现,这行代码是两周前某次“快速验证接口”时随手加的,当时想着“等联调完就抽出来”,结果一拖再拖,最后直接上线了。这里我踩了个坑:前端永远不该持有真正的生产 API Key,哪怕它看起来“只读”、“权限最小”。只要它能被 fetch 到,就等于公开了。

排查过程挺狼狈的。一开始我以为是 Vite 的 .env 配置没生效,试了 VITE_API_KEYimport.meta.env.VITE_API_KEY、甚至写了段 console.log(import.meta.env) 确认变量存在——都对。但抓包一看,请求头里还是固定字符串,完全没走变量。

折腾了半天发现,我在 src/utils/api.js 里写了这么一段:

// ❌ 错误示范:硬编码 + 没做环境判断
const API_BASE = 'https://jztheme.com/api';
const API_KEY = 'sk_live_abc123def456...'; // ← 这里!直接写死!

export function request(url, options = {}) {
  return fetch(${API_BASE}${url}, {
    headers: {
      'Authorization': Bearer ${API_KEY},
      'Content-Type': 'application/json',
      ...options.headers,
    },
    ...options,
  });
}

关键点来了:Vite 的环境变量只在构建时注入到 import.meta.env 中,而这段代码里的 API_KEY 是 JS 字面量,根本不会被替换。更糟的是,它还被 Webpack/Vite 当作普通字符串打包进了 chunk 里——谁都能在源码里搜到。

然后我又试了下把 key 放进 .env.production,结果发现 Vite 默认只加载 VITE_* 开头的变量。我写了 API_KEY=xxx,它压根不认。又浪费了二十分钟查文档,才看到那句小字:“Only variables prefixed with VITE_ are exposed to your code.”

还有个细节差点翻车:我把 key 改成 VITE_API_KEY 后,本地开发时用了 .env.development,里面填的是测试 key;但上线后 CI/CD 没传 VITE_API_KEY 环境变量,导致生产环境取到的是 undefined,整个 API 请求直接 401。后来加了兜底逻辑才稳住。

现在用的方案:环境变量 + 运行时校验 + 服务端中转(可选)

最终落地的方案分三层:

  • 第一层:所有 API 请求必须通过封装函数,且 key 只能从 import.meta.env.VITE_API_KEY 读取
  • 第二层:封装函数里加校验,如果 key 是 undefined 或长度异常(比如少于 20 字符),直接 throw 报错,防止静默失败
  • 第三层:生产环境强制走代理,把请求发到同域下的 /api/proxy,由后端转发并注入真实 key(这个不是必须的,但我们团队有现成的网关服务,顺手就上了)

核心代码就这几行(已上线跑了一周,没再漏):

// src/utils/api.js
const API_BASE = import.meta.env.VITE_API_BASE || 'https://jztheme.com/api';

// ⚠️ 关键校验:防 undefined、防空字符串、防明显无效值
function getApiKey() {
  const key = import.meta.env.VITE_API_KEY;
  if (!key) {
    throw new Error('❌ VITE_API_KEY is missing. Check your .env file.');
  }
  if (typeof key !== 'string' || key.trim().length < 20) {
    throw new Error(❌ Invalid VITE_API_KEY format: &quot;${key}&quot;);
  }
  return key.trim();
}

export function request(url, options = {}) {
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': Bearer ${getApiKey()},
    ...options.headers,
  };

  // 生产环境走代理,避免前端暴露真实 endpoint 和 key
  const isProd = import.meta.env.PROD;
  const finalUrl = isProd
    ? /api/proxy${url} // 后端会重写到 https://jztheme.com/api${url}
    : ${API_BASE}${url};

  return fetch(finalUrl, {
    method: options.method || 'GET',
    headers,
    body: options.body ? JSON.stringify(options.body) : undefined,
  }).then(res => {
    if (!res.ok) {
      throw new Error(HTTP ${res.status} ${res.statusText});
    }
    return res.json();
  });
}

配套的 .env.production 长这样(CI/CD 里注入):

VITE_API_BASE=https://jztheme.com/api
VITE_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

本地开发用 .env.development(Git 忽略):

VITE_API_BASE=https://staging.jztheme.com/api
VITE_API_KEY=sk_test_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

至于那个曾经裸奔的 mock-data.json,我直接删了,换成用 MSW(Mock Service Worker)在浏览器内存里 mock,连文件都不落地。

顺便提一句:如果你用的是 Next.js,别学我搞前端直连。Next 的 getServerSidePropsRoute Handlers 才是放真实 key 的地方。前端能拿到的,永远只是 token、session id 这种有时效、可吊销的东西。

改完之后又做了两件事:

  • 在 CI 流程里加了检查:grep -r "sk_live_" src/,命中就 fail
  • 给所有后端接口加了 referer 白名单和 user-agent 校验(虽然不防高级爬虫,但至少拦掉 80% 的瞎扫)

目前线上没问题,但还有一个小尾巴:我们有个老项目还在用 Webpack 4,它的 DefinePlugin 不支持动态环境变量,所以那边还是得靠构建参数传入——我打算下周抽时间把它升到 Webpack 5,不然哪天又手滑写个 process.env.API_KEY 就完了。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如怎么在 Webpack 4 里安全注入 key,或者怎么用 Cloudflare Workers 做轻量级中转),欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论