為什麼 .env 不等於 Secrets Management?從 Zeabur 事件理解環境變數管理

從 Zeabur 環境變數事件理解 .env、部署平台與 Secrets Management 的差異,以及權限、稽核、rotation 和撤銷機制。

CChia1104
文章22 分鐘閱讀

大家在開發專案時,應該都看過這類環境變數:

DATABASE_URL=...
OPENAI_API_KEY=...
JWT_SECRET=...

通常我們會把它們放在 .env,或交給部署平台的 Environment Variables 功能管理。這比把密碼直接寫進 source code 好,但不代表 secrets management 已經做好。

最近 Zeabur 發生的環境變數資料未授權存取事件,讓這個問題變得很具體:當一個部署平台替很多專案集中保存 API key、資料庫密碼和 cloud credential(可以用來驗證身分並存取服務的憑證),這些資料就不只是設定值,而會成為攻擊者可以繼續利用的入口。

先從 env 和 secret 的差異開始,再看 secrets management service 補上了哪些能力。

Zeabur 事件發生了什麼事?

根據 Zeabur 官方事件說明,Zeabur 在 2026 年 8 月 28 日 07:11 UTC 發現專案環境變數資料遭到未授權存取。

官方目前確認的攻擊路徑如下:

  1. 攻擊者取得 Zeabur 洩漏的內部 AWS 管理員 credential。
  2. 使用該 credential 進入 Zeabur 位於 Tokyo region 的共用 AWS cluster(多個服務共用的運算環境)。
  3. 從 cluster 取得控制平面的 VPN 存取權限。控制平面是負責管理服務、設定和資源的管理層,通常比單一 application 能接觸到更多資料。
  4. 連線到 Zeabur 的主要資料庫。
  5. 對使用者的 project environment variables 進行特定查詢與匯出。

從攻擊者後續的操作來看,目標主要是 AI service API key 和其他可以直接使用的 credential。官方列出的變數類型包含:

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
OPENAI_API_KEY
ANTHROPIC_API_KEY
GEMINI_API_KEY
OPENROUTER_API_KEY
GITHUB_TOKEN
STRIPE_SECRET_KEY
DATABASE_URL
MONGODB_URI
MYSQL_PASSWORD
POSTGRES_PASSWORD
REDIS_PASSWORD
JWT_SECRET
PRIVATE_KEY

即使使用者沒有採用上述名稱,只要變數內容符合 AWS、GitHub、Anthropic、OpenRouter、OpenAI 或 Stripe credential 的格式,也可能被辨識為敏感資料。

Zeabur 目前確認有環境變數資料被針對性查詢與匯出,但尚未找到完整資料庫被取得,或其他 customer data 被大量匯出的直接證據。官方也表示第三方鑑識仍在進行中,後續結論可能會更新。

問題不在於某個 API key 被放進 env,而在於這些 credential 集中儲存在同一個控制平面後,可能造成很大的 blast radius,也就是單一事件擴散後可能影響的系統、服務和資料範圍。

.env 不等於 Secrets Management

.env 主要只是一種設定檔格式。它解決的是「程式從哪裡讀取設定」,並不會自動解決 secret 的完整生命週期。

例如,下面這個檔案可以讓 Node.js 讀取環境變數:

NODE_ENV=production
PORT=8080
DATABASE_URL=postgres://user:[email protected]/app

但它沒有回答以下問題:

  • 誰可以讀取 DATABASE_URL
  • 這組資料庫密碼被哪些服務使用?
  • 這組 credential 什麼時候建立?
  • 什麼時候應該 rotation?
  • 發生外洩時,能不能快速 revoke?
  • 最近有哪些 application 讀取過它?
  • staging 和 production 是否使用不同的 credential?
  • 這個值有沒有被寫進 Git、Docker image 或 build log?

常見做法可以分成三個層次:

層次主要解決的問題
.env讓程式讀取本地或部署環境的設定
部署平台的 environment variables在 build 或 runtime 注入設定
Secrets manager管理權限、稽核、rotation、expiration 與分發

這三者的能力範圍不同。部署平台有可以填入 secret 的欄位,不代表它就具備完整的 secrets manager 能力。

先分清楚 build-time 和 runtime

環境變數常被混用,因為 build 和 runtime 使用的是不同階段的設定。build-time 指的是建置 application 時,runtime 則是 application 實際執行時。

Build-time environment variable

前端專案常見的 VITE_NEXT_PUBLIC_ 變數,很多是在 build 時被 bundler 讀取,最後直接寫進 JavaScript bundle。

例如:

VITE_API_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=...

這些值只要送到瀏覽器,就不再是秘密。使用者可以下載 bundle,或從瀏覽器的 Network、Source 和開發者工具中找到它們。

這也是我在前後端開發技術統整中提過的觀念:前端環境變數很多時候只是 build-time constant,不代表它是安全的 server secret。

下面這些通常可以是公開設定:

NODE_ENV=production
PORT=8080
VITE_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=...

但下面這些不應該進入前端 bundle:

OPENAI_API_KEY=...
AWS_SECRET_ACCESS_KEY=...
STRIPE_SECRET_KEY=...
DATABASE_URL=...
JWT_SECRET=...

如果前端需要呼叫第三方服務,通常應該讓後端代為呼叫,而不是把第三方服務的 secret 發給瀏覽器。

Runtime environment variable

Runtime env 是 application 啟動或執行時由平台注入的設定。這比 build-time env 更適合存放 server-side secret,但仍然不是完整的安全機制。

即使 secret 沒有被寫進 Git,它還是可能出現在:

  • 部署平台的 dashboard
  • CI/CD pipeline 設定
  • Docker image layer
  • build cache
  • application log
  • crash dump
  • process inspection
  • 備份資料庫

所以「沒有進 Git」只是基本要求,不是終點。

Secrets Management 真正要處理什麼?

OWASP Secrets Management Cheat Sheet把 secrets management 定義成一套包含 儲存、供應、稽核、rotation 和管理的流程,不只是把字串加密後放進資料庫。

1. 集中盤點 secret

專案變多之後,secret 很容易散落在不同地方:

  • .env 檔案
  • GitHub Actions
  • Docker Compose
  • Kubernetes manifest
  • 部署平台 dashboard
  • CI/CD variables
  • 訊息平台私訊
  • 團隊成員的 password manager

專門的 secrets manager 可以將這些資料集中管理,並且建立 project、environment 和 service 的關係。

例如:

my-app
├── development
├── staging
└── production

接著 application 只取得自己需要的值,而不是讀取整個團隊的 secret。

集中化讓我們比較容易追蹤 這組 key 被誰使用,也會讓 secrets manager 成為更有價值的攻擊目標。保存的資料越多,單一服務被攻陷時可能影響的專案就越多。

2. Least privilege

Least privilege(最小權限)是指只授予使用者或 application 完成工作所需的最低權限。不應該讓每一個工程師、CI job 或 application 都可以讀取全部 secrets。

比較合理的權限模型可能是:

api-production
  ├── read production database credential
  ├── read payment provider key
  └── read jwt signing secret

worker-production
  └── read queue credential

frontend-production
  └── read public configuration only

這些權限可以依照以下維度拆分:

  • Project
  • Environment
  • Service
  • Team
  • User
  • Machine identity
  • Role

OWASP 的建議是套用 least privilege。工程師不應該預設擁有整個 secrets store 的讀取權限,application 也不應該拿到超出工作範圍的 credential。

3. Audit log

Audit log(稽核紀錄)是記錄誰在什麼時間,以什麼身分對系統做了什麼事。對 secrets manager 來說,如果只知道 這個值存在,卻不知道 誰讀過這個值,發生事件時仍然很難調查。

Secrets manager 至少應該記錄:

  • 誰請求 secret
  • 哪個 application 或 machine identity 請求
  • 從哪個 project 和 environment 請求
  • 請求是否被允許
  • 什麼時間讀取
  • 誰更新或刪除了 secret
  • 誰修改了 access policy
  • 是否有人嘗試使用已過期的 secret

理想上,audit log 也不應該和 secret 放在同一個權限邊界裡,避免攻擊者入侵後可以同時刪除資料和清除痕跡。

4. Rotation、expiration 和 revocation

一組 API key 建立後放著不動,通常會一直有效到有人手動撤銷。這種 key 的問題是:使用時間越長,可能散落在越多地方,也越難知道它到底被誰使用。

Secret 至少應該有以下生命週期:

Creation -> Distribution -> Usage -> Rotation -> Revocation -> Expiration

其中幾個概念很容易混淆:

  • Rotation:建立新的 credential,讓 application 從舊值切換到新值。
  • Revocation:讓某個 credential 立刻失效。
  • Expiration:在預定時間後讓 credential 失效。

如果 rotation 只能靠人工複製貼上,最後很可能因為怕影響 production 而一直不做。OWASP 建議盡可能自動化 rotation,並在 application 端設計好切換期間的 retry 和相容邏輯。

另一個重要做法是不要讓所有服務共用同一組 key:

錯誤:
所有 project -> 同一組 `OPENAI_API_KEY`

較好的做法:
project-a -> project-a-openai-key
project-b -> project-b-openai-key

project-a 的 key 被洩漏時,只需要撤銷那一組,不會讓所有服務一起中斷。

5. Secret injection

Secret injection(秘密注入)是指在 application 執行或部署時,才把 secret 提供給 application,而不是把它寫進 source code 或 Docker image。比較理想的架構,是由 application 或 deployment pipeline 使用受到限制的 machine identity(機器身分,也就是代表 application 或 CI/CD job 的非人員身分),向 secrets manager 請求自己需要的資料:

CI/CD pipeline
  ↓ workload identity
Secrets manager
  ↓ 只取得指定 environment 的 secrets
Deployment runtime
  ↓ 注入 application
Application

這比把一組可以讀取所有 secret 的管理員 token 放進 CI/CD 設定中好很多。

不過要注意,secret 最後仍然會在 application 執行過程中以某種形式存在。Secrets manager 能夠縮小暴露範圍,但無法讓一個已經被攻陷的 application 讀不到它本來就有權限使用的 secret。

Cloudflare Secrets Store 整合 Workers

以 Cloudflare 為例,除了直接用 wrangler secret put 管理單一 Worker 的 secret,Cloudflare 也提供 Secrets Store。它可以在 account 層級集中保存 secret,再透過 binding(把外部資源掛到 Worker 的設定)將指定的 secret 提供給 Cloudflare Workers 使用。

Cloudflare 目前官方文件列出的 Secrets Store 整合對象是 Cloudflare WorkersAI Gateway。它不是讓任意外部主機直接以 SDK 連線的通用 remote .env service。若你的 application 部署在 VPS、Docker、Kubernetes 或其他 self-hosted 環境,就不能直接使用 Workers 的 env.<binding>.get()

這種做法和把一組 Cloudflare API token 放進 Worker 的環境變數裡不太一樣。Worker 只需要被授予指定 secret 的 binding,不需要在程式碼或 wrangler.jsonc 裡保存可以操作 Cloudflare API 的管理員 token。

建立 account secret

首先在 Cloudflare account 建立 Secrets Store,接著使用 Wrangler 建立 secret。下面的 <STORE_ID> 要替換成 Cloudflare account 中實際的 store ID:

npx wrangler secrets-store secret create <STORE_ID> \\
  --name OPENAI_API_KEY \\
  --scopes workers \\
  --remote

執行指令後,Wrangler 會要求你輸入 secret value。Cloudflare 官方文件指出,secret 建立後就不再提供查看原始值的功能;這也代表你需要在外部保留好原始 credential 的 recovery 或 rotation 流程。

建立 secret 需要相應的 Secrets Store 權限,而 secret 也要設定 workers scope,之後才能綁定到 Worker。不要直接把 <STORE_ID> 或 secret value 寫進 repository。

在 Worker 設定 binding

接著在 wrangler.jsonc 中加入 Secrets Store binding:

{
  "name": "api-worker",
  "main": "./src/index.ts",
  "compatibility_date": "2026-08-30",
  "secrets_store_secrets": [
    {
      "binding": "OPENAI_API_KEY",
      "store_id": "<STORE_ID>",
      "secret_name": "OPENAI_API_KEY"
    }
  ]
}
  • 這裡的 binding 是 Worker 程式中使用的變數名稱
  • store_id 指向 Secrets Store
  • secret_name 則是剛剛建立的 account secret 名稱。

要注意,部署具有 Secrets Store binding 的 Worker,需要 Cloudflare account 中的 Super Administrator 或 Secrets Store Deployer role。這和「可以在自己的程式裡使用 secret」是兩件事:管理 binding 的人需要較高的部署權限,而 Worker 執行時只應該取得必要的 secret。

在程式中讀取 secret

Secrets Store binding 會出現在 Worker handler 的 env 物件上,而且需要非同步呼叫 get() 取得內容:

interface Env {
  OPENAI_API_KEY: {
    get(): Promise<string>;
  };
}

export default {
  async fetch(_request: Request, env: Env): Promise<Response> {
    const apiKey = await env.OPENAI_API_KEY.get();

    const response = await fetch("https://api.openai.com/v1/models", {
      headers: {
        Authorization: `Bearer ${apiKey}`,
      },
    });

    return new Response(await response.text(), {
      status: response.status,
      headers: {
        "Content-Type": "application/json",
      },
    });
  },
};

這個範例只是示範取得 secret 和呼叫第三方 API 的位置。實際專案中仍然要避免把 apiKey 印到 log、回傳給 client,或在錯誤訊息中帶出完整內容。也應該限制 OpenAI key 本身的權限與用量,並在第三方服務端保留可以追蹤和撤銷的設定。

Cloudflare 的這個整合方式有幾個值得注意的地方:

  • secret 在 account 層級管理,但只會被明確綁定的 Worker 使用。
  • binding 設定的是 secret 的參照,不是把 secret value 寫進 wrangler.jsonc
  • Worker 程式透過 env.<binding>.get() 取得值,而不是直接把 credential 寫在 source code。
  • Secrets Store 的 permission scope 和 Worker binding 權限需要分開檢查。

它可以減少 secret 散落在 repository、CI/CD 設定和不同部署專案中的情況,但 Worker 執行時仍然可以取得自己被授予的明文 secret。如果 Worker 本身已經被攻陷,攻擊者仍可能濫用該 Worker 的權限。因此,Cloudflare Secrets Store 不是把風險消除,而是讓 secret 的儲存、授權和分發邊界更清楚

使用專門服務後,真的會比較安全嗎?

答案不是單純的「會」或「不會」。把環境變數從部署平台搬到另一個 secrets manager,實際上是重新分配信任邊界:

原本:
Application

Deployment platform environment variables

Deployment platform database

搬遷後:
Application

Secrets manager

Secrets manager database

新的服務可能提供更好的:

  • 細緻權限
  • audit log
  • secret versioning
  • rotation
  • machine identity
  • dynamic credentials

但它也會成為新的高價值目標:

  • 管理員帳號可能被盜
  • service token 可能被洩漏
  • 備份或 export 可能暴露 secret
  • CI/CD integration 可能有過大權限
  • 服務商的內部人員或控制平面可能成為攻擊路徑
  • 服務中斷時 application 可能無法取得必要設定

因此不能只看產品是否宣稱「encrypted at rest」。真正要問的是:

誰可以在什麼情況下取得解密後的 secret?

資料庫加密是必要條件,但如果攻擊者已經拿到可以解密資料的內部管理員 credential,單純的 at-rest encryption 並不能阻止資料被讀取。


其實回頭來看 Zeabur 的狀況比較偏向這部分,駭客起初透過洩漏的 AWS 權限來獲取主要 DB 資料,Zeabur 的專案環境變數設定所扮演的角色就是他們自己服務的 secret manager

Cloud provider 的 managed secret manager

例如:

  • AWS Secrets Manager
  • AWS Systems Manager Parameter Store
  • Azure Key Vault
  • Google Secret Manager

這類服務適合已經深度使用 AWS、Azure 或 GCP 的團隊,因為可以直接整合 cloud IAM、service identity、network policy 和 audit log。

優點是 application 不一定需要另外保存長期 credential;缺點是權限模型通常比較複雜,跨 cloud 和本地開發的體驗也不一定一致。

通用型 secrets management platform

例如:

  • HashiCorp Vault
  • Infisical
  • Doppler
  • 1Password Secrets Automation

這類工具通常會提供 project、environment、CLI、SDK、CI/CD integration、access policy 和 audit log。部分工具也支援 dynamic secrets、certificate management 或 self-hosting。

Infisical 官方文件為例,它目前將產品定位在 secrets、certificates 和 privileged access management,並提供 secrets rotation、dynamic credentials、access approval 和 audit 等能力。

但產品功能多,不代表不需要理解權限模型。最重要的仍然是確認 production secret 是否能和 development 隔離,以及 application 的 machine identity 是否只拿到必要資料。

Developer-first 的 environment management

另一類工具比較接近「把 .env 管理做好」,通常強調:

  • 本地開發同步
  • CLI
  • 多環境管理
  • 團隊協作
  • CI/CD 注入

對小型團隊來說,這可以大幅減少手動複製 .env 的情況。不過在選擇前,仍然要確認它是否提供 production 所需的細緻權限、audit log、rotation、machine identity 和事件處理能力。

能夠同步 .env,不代表它就等同於 enterprise secrets manager。

小型專案可以怎麼做?

不是每個 side project 一開始都需要部署完整的 Vault。重要的是先建立正確的分層和習慣。

本地開發

.env.local
  ├── 不進 Git
  ├── 使用 .gitignore
  └── 只放本地需要的 credential

Repository 裡可以放不含敏感值的範例:

# .env.example
DATABASE_URL=
OPENAI_API_KEY=

CI/CD

使用 CI/CD 平台提供的 encrypted secrets,並且限制到特定 repository、environment 或 job。不要把整組 production secret 設定成所有 pipeline 都能讀取的全域變數。

Production

如果已經使用 AWS、Azure 或 GCP,可以優先評估該 cloud provider 的 managed secret manager,搭配 application identity 取得 secret。

如果專案需要跨 cloud、本地部署、團隊環境同步或 self-hosting,再評估 Vault、Infisical 或其他通用型工具。

事件發生後,應該怎麼處理?

只要有理由認為 secret 可能被讀取,就不能只刪掉部署平台上的那筆資料。正確做法應該是到原本簽發 credential 的服務商進行 revoke 或 rotate

建議依照以下順序處理:

  1. 列出所有可能受影響的 project、environment 和 service。
  2. 依照 credential 類型撤銷或替換 API key、database password、SSH key 和 signing secret。
  3. 檢查第三方服務的 usage、billing、login 和 audit log。
  4. 檢查資料庫、server 和 cloud resource 是否有異常連線。
  5. 將 production 與 staging credential 分開。
  6. 確認新的 credential 只授予必要權限。
  7. 保留事件時間線和處理紀錄。
  8. 檢查 secret 是否曾經出現在 log、image、cache 或備份中。

Zeabur 官方在事件說明中也建議受影響的使用者立即 revoke 和 replace 相關 credential,並檢查第三方服務是否有異常存取、用量或費用。


openai-apikey-breach

還記得自己在 8/28 中午先收到 OpenAI 提醒有兩組 API Key 遭濫用才注意到,結果洩漏的兩組 key 都是 2023 年使用的(並部署在 zeabur 上),起初還在想是哪裡洩漏的結果後來是看到 Thread 上有人講才知道,後來到目前為止都還沒收到 Zeabur 官方信件說自己有 key 洩漏...

不過自己後續也應該清理並加以管理這些未使用的 API Key,並設定使用期限才對

hacker-is-playing

openai-apikey-breach-usage

不過因為自己有設定運用範圍跟使用上限,所以第一時間也很感謝 OpenAI 幫忙做限流,才得以避免近一步的影響

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104