保護你的API KEY (React 前端)
保護你的 API Key(React / Next.js 前端)
TL;DR前端能讀到的 env var 都不是 secret。NEXT_PUBLIC_* / REACT_APP_* 前綴變數會 build 時 inline 進 bundle,訪客 F12 看 source code 就能找到。真 secret 必須走 server-side proxy:client 打你的 API → server 拿真 secret 呼叫第三方。
先搞清楚:env var 兩條獨立邊界
最常被混淆的概念
- Runtime 安全邊界(會不會被打包進 client bundle)
- Dashboard 可見度邊界(Vercel UI 能否顯示 plaintext 值)
這兩個是獨立的,不要混為一談。
Runtime 邊界(主要)
| 寫法 | 哪裡能讀? | 是否 ship 給 client? |
|---|---|---|
STRIPE_SECRET_KEY=sk_xxx | server only(API route / Server Component / middleware) | ❌ 不會 |
NEXT_PUBLIC_GA_ID=UA-xxx | server + client | ✅ build 時 inline 進 bundle |
REACT_APP_API_URL=...(CRA) | server + client | ✅ inline 進 bundle |
Dashboard 邊界(次要)
| Vercel UI 設定 | 影響 |
|---|---|
| Plaintext | dashboard 能看到值 |
| Sensitive | dashboard 看不到值(只有 set 時) |
Sensitive vs Plaintext 對 client bundle 完全沒影響,只是給管理員看的 UI 差異。是否曝光給 client,只看前綴。
Next.js 的兩種寫法
1. .env.local + 無前綴(server-only secret)
# .env.local(加進 .gitignore)
STRIPE_SECRET_KEY=sk_live_xxx
DATABASE_URL=postgresql://...
OPENAI_API_KEY=sk-xxx
// app/api/checkout/route.ts(server-side)
export async function POST(req: Request) {
// ✅ 只能在 server 讀
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const session = await stripe.checkout.sessions.create({ /* ... */ });
return Response.json({ url: session.url });
}
// ❌ Client component 讀 → undefined
'use client';
function Checkout() {
console.log(process.env.STRIPE_SECRET_KEY); // undefined
}
2. NEXT_PUBLIC_* 前綴(刻意公開)
NEXT_PUBLIC_GA_ID=G-XXXXX
NEXT_PUBLIC_FIREBASE_API_KEY=AIza...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
// ✅ Client / Server 都讀得到
function GAScript() {
return <Script src={`https://googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`} />;
}
==NEXT_PUBLIC_ = "opt-in to leak",不是 "opt-in to use"。只有 本來就該公開的識別碼== 才該加前綴:
適合 NEXT_PUBLIC_ | 不適合 |
|---|---|
| Google Analytics ID | Stripe Secret Key |
| Firebase config(apiKey) | Database password |
| Stripe Publishable Key | OpenAI API Key |
| 公開的 endpoint URL | JWT Signing Secret |
CRA(Create React App)寫法
# .env(也加進 .gitignore)
REACT_APP_API_URL=https://api.example.com
REACT_APP_GA_ID=G-XXXXX
CRA 的規則:
- 所有變數都要
REACT_APP_前綴,沒前綴 CRA 直接 ignore - 所有
REACT_APP_*都會 inline 進 bundle,沒有 server-only 概念 - CRA 沒有 server side,所以 CRA 完全不適合處理 secret
// 直接讀
const apiUrl = process.env.REACT_APP_API_URL;
CRA 已退場2025-02 React 官方移除 CRA 推薦。新專案直接用 Vite / Next.js。Vite 的等效機制是 import.meta.env.VITE_* 前綴。
// Vite
const url = import.meta.env.VITE_API_URL;
真 secret 怎麼用?Server-side Proxy
如果你需要在 client 互動的功能用到真 secret(例 OpenAI API),絕對不要把 secret 給 client:
// ❌ 錯誤:把 OpenAI key 包進 NEXT_PUBLIC_
const openai = new OpenAI({ apiKey: process.env.NEXT_PUBLIC_OPENAI_KEY });
正確架構:
┌──────────┐ ┌────────────────┐ ┌──────────┐
│ Client │ POST │ Your Server │ API │ OpenAI │
│ (no key) │ ──────> │ (has secret) │ ──────> │ │
└──────────┘ └────────────────┘ └──────────┘
// app/api/chat/route.ts(server)
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // ✅ server-only
export async function POST(req: Request) {
const { prompt } = await req.json();
// 在這裡可以加 ==auth 檢查、rate limit、prompt 過濾==
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
});
return Response.json({ result: completion.choices[0].message.content });
}
// client component
async function askAI(prompt: string) {
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
return res.json();
}
server 是「屏障+控制點」 — 防止濫用、加 rate limit、記 log,client 看不到 secret。
Firebase 的特殊情況
Firebase 前端 config(包含 apiKey) 是 設計成可公開的識別碼,不是 secret:
// 完全 OK 公開
const firebaseConfig = {
apiKey: 'AIzaSyB-XXX',
authDomain: 'my-app.firebaseapp.com',
projectId: 'my-app',
};
為什麼 OK:Firebase apiKey 只是用來識別 project,真正控制存取的是 [Security Rules](../後端與資料庫/Firebase 前端API 是安全的.md)。
但仍然:
NEXT_PUBLIC_FIREBASE_API_KEY加前綴 → 進 bundle ✅ 合理- Firebase Service Account JSON(server-side admin)→ 絕對不能進 bundle ❌
.gitignore 必加
.env
.env.local
.env.*.local
# 但 .env.example 應該 commit(範本)
!.env.example
.env.example 列出需要的 key 但不放真值:
# .env.example
STRIPE_SECRET_KEY=
NEXT_PUBLIC_GA_ID=
DATABASE_URL=
新成員 clone 後 cp .env.example .env.local 填值即可。
常見壞 pattern(code review 抓)
// ❌ 1. 把 secret inline 到 JSX
<div>{process.env.STRIPE_SECRET_KEY}</div>
// ❌ 2. 序列化進 props
export async function getServerSideProps() {
return { props: { secret: process.env.STRIPE_SECRET_KEY } };
}
// ❌ 3. API route 主動回傳 secret
export async function GET() {
return Response.json({ key: process.env.STRIPE_SECRET_KEY });
}
// ❌ 4. 誤加 NEXT_PUBLIC_ 前綴
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_xxx
自我檢查清單
- 值在 client code 讀得到嗎?(grep 最終 bundle 驗:
npm run build && grep -r 'sk_live' .next/)- 有沒有被 序列化到 getXxxProps 的 props?
- API route 是否主動把 secret 寫進 response?
- 是否誤加
NEXT_PUBLIC_前綴給真 secret?- Server-side security rule(Firebase Rules / DB RLS / middleware auth)有沒有擋?
已外洩的 secret 怎麼辦
一旦 push 到 GitHub立刻 rotate。即使你 git 強制 push 蓋掉,GitHub 內部 cache + 其他人 fork 後仍有。
- 重新生成新 key(Stripe / OpenAI 等 dashboard)
- 廢除舊 key
- 檢查使用記錄(有無被濫用)
- 加 git-secrets / trufflehog 預防再犯
工具推薦
| 工具 | 用途 |
|---|---|
| 1Password / Bitwarden | Secret 的 Single Source of Truth |
| Vercel / Cloudflare Pages env | 部署平台的 runtime injection |
| Doppler / Infisical | 團隊集中式 secret manager |
| git-secrets / pre-commit hook | 防止 commit 含 secret |
Solo 開發者建議
- 1Password 存所有 secret(SoT)
- Vercel dashboard 為每個環境設(沒事不開 Sensitive,debug 較方便)
- pre-commit hook 跑
git-secrets --scan防止失誤- 重要 key 設 quota / budget alert