首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau
百百 BLOG
首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau

Kurau Blog

「隨心而寫,真真假假,都是我」

一個記錄生活、輸出興趣的個人空間。
遊戲、影視、閱讀、學習……每一段體驗都值得留下文字。

頁面導覽

  • 學習紀錄
  • 遊戲心得
  • 影視Life
  • 書單
  • 委託作品與二創
  • Kurau
  • 合作邀請

找到我

歡迎來 Discord 找我聊天!

“曾經發生的事不可能忘記,只是暫時想不起來而已。”-《神隱少女》

© 2026 Kurau All rights reserved

資安

保護你的API KEY (React 前端)

By Kurau·2023-04-10·Updated 2026-05-09·6 分鐘閱讀

保護你的 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 兩條獨立邊界

最常被混淆的概念
  1. Runtime 安全邊界(會不會被打包進 client bundle)
  2. Dashboard 可見度邊界(Vercel UI 能否顯示 plaintext 值)

這兩個是獨立的,不要混為一談。

Runtime 邊界(主要)

寫法哪裡能讀?是否 ship 給 client?
STRIPE_SECRET_KEY=sk_xxxserver only(API route / Server Component / middleware)❌ 不會
NEXT_PUBLIC_GA_ID=UA-xxxserver + client✅ build 時 inline 進 bundle
REACT_APP_API_URL=...(CRA)server + client✅ inline 進 bundle

Dashboard 邊界(次要)

Vercel UI 設定影響
Plaintextdashboard 能看到值
Sensitivedashboard 看不到值(只有 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
bash
// 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 });
}
typescript
// ❌ Client component 讀 → undefined
'use client';
function Checkout() {
  console.log(process.env.STRIPE_SECRET_KEY);  // undefined
}
tsx

2. NEXT_PUBLIC_* 前綴(刻意公開)

NEXT_PUBLIC_GA_ID=G-XXXXX
NEXT_PUBLIC_FIREBASE_API_KEY=AIza...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
bash
// ✅ Client / Server 都讀得到
function GAScript() {
  return <Script src={`https://googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`} />;
}
tsx

==NEXT_PUBLIC_ = "opt-in to leak",不是 "opt-in to use"。只有 本來就該公開的識別碼== 才該加前綴:

適合 NEXT_PUBLIC_不適合
Google Analytics IDStripe Secret Key
Firebase config(apiKey)Database password
Stripe Publishable KeyOpenAI API Key
公開的 endpoint URLJWT Signing Secret

CRA(Create React App)寫法

# .env(也加進 .gitignore)
REACT_APP_API_URL=https://api.example.com
REACT_APP_GA_ID=G-XXXXX
bash

CRA 的規則:

  • 所有變數都要 REACT_APP_ 前綴,沒前綴 CRA 直接 ignore
  • 所有 REACT_APP_* 都會 inline 進 bundle,沒有 server-only 概念
  • CRA 沒有 server side,所以 CRA 完全不適合處理 secret
// 直接讀
const apiUrl = process.env.REACT_APP_API_URL;
tsx
CRA 已退場
2025-02 React 官方移除 CRA 推薦。新專案直接用 Vite / Next.js。Vite 的等效機制是 import.meta.env.VITE_* 前綴。
// Vite
const url = import.meta.env.VITE_API_URL;
typescript

真 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 });
typescript

正確架構:

┌──────────┐         ┌────────────────┐         ┌──────────┐
│  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 });
}
typescript
// client component
async function askAI(prompt: string) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    body: JSON.stringify({ prompt }),
  });
  return res.json();
}
tsx

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',
};
typescript

為什麼 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
gitignore

.env.example 列出需要的 key 但不放真值:

# .env.example
STRIPE_SECRET_KEY=
NEXT_PUBLIC_GA_ID=
DATABASE_URL=
bash

新成員 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
tsx
自我檢查清單
  1. 值在 client code 讀得到嗎?(grep 最終 bundle 驗:npm run build && grep -r 'sk_live' .next/)
  2. 有沒有被 序列化到 getXxxProps 的 props?
  3. API route 是否主動把 secret 寫進 response?
  4. 是否誤加 NEXT_PUBLIC_ 前綴給真 secret?
  5. Server-side security rule(Firebase Rules / DB RLS / middleware auth)有沒有擋?

已外洩的 secret 怎麼辦

一旦 push 到 GitHub
立刻 rotate。即使你 git 強制 push 蓋掉,GitHub 內部 cache + 其他人 fork 後仍有。
  1. 重新生成新 key(Stripe / OpenAI 等 dashboard)
  2. 廢除舊 key
  3. 檢查使用記錄(有無被濫用)
  4. 加 git-secrets / trufflehog 預防再犯

工具推薦

工具用途
1Password / BitwardenSecret 的 Single Source of Truth
Vercel / Cloudflare Pages env部署平台的 runtime injection
Doppler / Infisical團隊集中式 secret manager
git-secrets / pre-commit hook防止 commit 含 secret
Solo 開發者建議
  1. 1Password 存所有 secret(SoT)
  2. Vercel dashboard 為每個環境設(沒事不開 Sensitive,debug 較方便)
  3. pre-commit hook 跑 git-secrets --scan 防止失誤
  4. 重要 key 設 quota / budget alert

目錄

    ◆ 相關文章

    • cookie & sessionStorage加密 (crypto-js)

      2026-05-09
    • Termly 第三方cookie教學

      2026-05-09
    • 限制特定網頁

      2026-05-09
    ← 上一篇複製你的hightlight 程式碼下一篇 →上一步 下一步 與 清除的邏輯規則

    ◆ 關於作者

    Kurau

    個人寫作 / 創作的 SoT,記錄遊戲、影視、學習與生活。

    更多 Kurau 的文章