
昨天鏢局正式開張,App 也順利呼叫到雲端的函式。
不過開張第一天,也發現了一件讓人不太放心的事:
任何人只要知道網址,都能直接呼叫這支函式,不一定是自家的 App。
怎麼確認上門的,真的是自家的 App?
呼叫 Gemini 這類付費服務的金鑰,要放在哪裡?
今天就替鏢局增添 通行守衛(App Check)和 金鑰保險箱(Secret Manager)!
App Check 會替每一個請求附上一張「通行證」(App Check 權杖),
證明請求來自正版的 App,而且跑在真的裝置上。
通行證由誰來開,依平台不同:
| 環境 | 驗證方式 | 說明 |
|---|---|---|
| Android 實機 | Play Integrity |
由 Google Play 服務確認 App 與裝置 |
| iPhone 實機 | App Attest |
由 Apple 確認 App 與裝置 |
| 模擬器 | debug provider | 模擬器不是真的裝置,改用開發用的偵錯權杖 |
函式開啟檢查之後,沒有通行證、或通行證無效的請求,一律擋下。


昨天執行 flutterfire configure 時,
App 已經註冊到 Firebase,這裡只要替兩個平台各選一種驗證方式:
① 選擇 App Attest

② 輸入團隊 ID

團隊 ID 可以在 Apple Developer 網站「Membership details」查到:


小提醒:權杖存留時間
團隊 ID 下面的「權杖存留時間」,是通行證的有效期限,
預設 1 小時;Android 的註冊畫面也有這一欄。不用擔心一小時後 App 就不能用:SDK 大約在有效期限過一半時,
就會自動換一張新的通行證。設短一點比較安全,但驗證會更頻繁、也更快用掉配額;
官方認為預設的 1 小時適合大多數 App(官方說明),維持預設就好。
① 選擇 Play Integrity

② 輸入 SHA-256 憑證指紋

Android App 安裝前都要先用金鑰簽章。
簽章就像替 App 蓋章,證明「這是我做的、沒被改過」。SHA-256 指紋就像這把金鑰的身分證字號,
Firebase 靠它確認發出請求的是你簽章的 App。
終端機查詢 SHA-256 ,輸入指令:
keytool -list -v -keystore ~/.android/debug.keystore \
-alias androiddebugkey -storepass android -keypass android
debug 和 prod 所用的金鑰簽章是不一樣的,指紋也不同。


展開「進階設定」會看到三個選項:
App 是不是從 Google Play 來的」手機本身可不可信」| 選項 | 檢查什麼 | 預設 |
|---|---|---|
必須有應用程式完整性標籤「PLAY_RECOGNIZED」 |
App 和簽章憑證,跟 Google Play 發布的版本一致 | 要求 |
必須具備帳戶詳細資料標籤「LICENSED」 |
使用者是從 Google Play 安裝或更新這個 App | 不要求 |
可接受的最低裝置完整性等級 |
手機至少要達到哪個等級才放行 | 不要確切檢查 |
第三個選項「最低裝置完整性等級」,由低到高有四種:
| 等級 | 意思 | 需要留意 |
|---|---|---|
| 不要確切檢查裝置完整性等級 | 不指定等級 | Play Integrity 仍可能自己做額外的檢查,這個設定關不掉 |
| 完整性:基本 | 通過基本的系統檢查;bootloader 解鎖、沒經過認證的裝置也可能拿到 | 要先在 Google Play Console 開啟;Android 13 以上、不是從 Google Play 安裝的使用者拿不到 |
| 完整性:裝置等級 | 真的、經過認證的 Android 裝置 | 所有 App 都拿得到,不用另外開啟 |
| 完整性:高 | 經過認證,而且有近期的安全性更新(Android 13 以上要一年內) | 要先在 Google Play Console 開啟;官方建議只在「只在 Google Play 發布」時考慮 |
「基本」聽起來最寬鬆,但對沒上架的 App 並不會比較寬鬆:
Android 13 以上的手機拿不到這個標籤,還是要達到「裝置等級」才會放行。
依 App 發布管道,官方建議設定:
| App 的發布管道 | PLAY_RECOGNIZED | LICENSED | 最低裝置完整性等級 |
|---|---|---|---|
| 只在 Google Play | 要求 | 要求 | 不要確切檢查 |
| 只在 Google Play 以外 | 不要求 | 不要求 | 完整性:裝置等級 |
| 兩邊都有 | 要求 | 不要求 | 不要確切檢查 |
要特別留意第一個選項:
PLAY_RECOGNIZED 預設是勾選的,
但沒有在 Google Play 發布的 App 拿不到這個標籤(官方說明)。還沒上架、自己用 flutter run 裝到手機的版本,照預設設定會被擋下。
這次的示範 App 就是這種情況,所以照「只在 Google Play 以外」設定:
① 安裝套件:
flutter pub add firebase_app_check
② 在 Firebase 初始化之後、呼叫函式之前啟用 App Check:
// 模擬器用 debug provider,token 在執行時帶入(見下一節)
const appCheckDebugToken =
String.fromEnvironment('APP_CHECK_DEBUG_TOKEN');
const useAppCheckDebug = appCheckDebugToken != '';
// App Check 要在呼叫其他 Firebase 服務之前啟用
await FirebaseAppCheck.instance.activate(
providerAndroid: useAppCheckDebug
? const AndroidDebugProvider(debugToken: appCheckDebugToken)
: const AndroidPlayIntegrityProvider(),
providerApple: useAppCheckDebug
? const AppleDebugProvider(debugToken: appCheckDebugToken)
: const AppleAppAttestProvider(),
);
以上兩個平台都一樣;接下來只有 iOS 要多做兩步。
Android 不用另外設定,Flutter 套件已經帶入 Play Integrity。
③ iOS:在 Xcode 開啟 App Attest
Runner 的「Signing & Capabilities」→「+ Capability」→ App Attest


④ iOS:把 Runner.entitlements 裡的 App Attest Environment 改成 production

App Check 目前不接受 App Attest 測試環境(sandbox)產生的權杖,
所以就算是開發中的版本,環境也要設成 production(官方說明)。
模擬器拿不到 Play Integrity、App Attest 的驗證,要改用「偵錯權杖」:
在主控台產生一組值,App 帶著同一組值,就能換到通行證。
① 在主控台產生偵錯權杖:

主控台畫面的 「偵錯符記」,指的就是 「偵錯權杖」。
② 把這組值存成 app_check_debug.json,並加進 .gitignore:
{
"APP_CHECK_DEBUG_TOKEN": "主控台產生的那組值"
}
③ 在模擬器執行時帶入:
flutter run --dart-define-from-file=app_check_debug.json
小提醒:
iOS 和 Android 在主控台是兩個 App,偵錯權杖要各自登記;
兩種模擬器都要用的話,另一個 App 也要新增同一組值。
偵錯權杖是機密,拿到它就能冒充你的 App,不要放進版本控制,也不要放進正式版。
App 端加上 App Check 之後,函式的 log 會記錄每個請求的檢查結果。
還沒開啟強制檢查時,通行證無效的請求照樣放行,log 會寫:
Allowing request with invalid AppCheck token
because enforcement is disabled
可以先從 log 確認自家 App 的請求都是 "app":"VALID",再開啟強制檢查:
exports.greet = onCall(
// 沒有有效 App Check 權杖的請求,一律拒絕
{enforceAppCheck: true},
(request) => {
// ……跟昨天一樣
},
);
firebase deploy --only functions:greet
| 呼叫方式 | 結果 |
|---|---|
| curl,沒有通行證 | 401 UNAUTHENTICATED |
| curl,帶一張假的通行證 | 401 UNAUTHENTICATED |
| Android 模擬器(debug provider) | 成功 |
| Android 平板(Play Integrity) | 成功 |
| iPhone(App Attest) | 成功 |
直接用 curl 呼叫會被擋下,只有自家的 App 叫得動這支函式。
小提醒:
測試用的 Android 手機如果沒有通過 Google 認證,Play Integrity 會驗證失敗;
可以在 Play 商店 →「設定」→「關於」→「Play 安全防護認證」確認。我手邊有一支「裝置未通過認證」的手機,實測真的過不了XD
把裝置完整性放寬到不檢查也一樣過不了;換成另一台平板就正常。
| 金鑰 | 是不是機密 | 放在哪裡 |
|---|---|---|
| Firebase API key | 不是(Day13 提過) | App 裡的 firebase_options.dart |
| Gemini API 金鑰 | 是,別人拿去用,帳單得照付 | 保險箱 Secret Manager |
Gemini 的金鑰不能打包進 App,App 裡的東西都可能被取出;
也不適合直接寫在函式的程式碼裡,程式碼會跟著進版本控制。
比較好的做法:放進 Google Cloud 的保險箱 Secret Manager,函式執行時才取用。
呼叫 Google 自家的服務(例如 Cloud STT)時,
函式可以用自己的身分(服務帳戶)呼叫,連金鑰都不需要。
下一篇實測 Cloud STT 時會用到。
金鑰在 Google AI Studio 建立。
用的是免費層還是付費層,看的是金鑰所在的專案:專案沒有連結帳單帳戶,Billing Tier 顯示「Free tier」,就是免費層。
今天用免費層實作:
① 在「API Keys」頁面,確認專案的 Billing Tier 顯示「Free tier」

② 點選這個專案的金鑰

列表裡沒有金鑰的話,按右上角的「Create API key」,選這個專案建立一把。
③ 在「API key details」按「Copy key」,複製金鑰

免費層有一條要特別留意的條款(Gemini API Additional Terms of Service):
Do not submit sensitive, confidential, or personal information to the Unpaid Services.不要把敏感、機密或個人資訊送進免費服務。
所以這次只送不含個資的測試文字。
要處理個人資料,得改用付費層;付費層要先預付,Billing 最少 US$5。
在函式專案的資料夾,終端機輸入指令,照提示貼上金鑰:
firebase functions:secrets:set GEMINI_API_KEY
貼上後,Secret Manager 會存成第 1 版:
✔ Enter a value for GEMINI_API_KEY:
✔ Created a new secret version
projects/494602719837/secrets/GEMINI_API_KEY/versions/1
金鑰只在終端機輸入,不會出現在程式碼裡;之後換金鑰,會存成第 2 版。
先安裝 Gemini 的 SDK:
cd functions
npm install @google/genai
再在 functions/index.js 加上 askGemini,
onCall、HttpsError、logger 沿用昨天引入的:
const {defineSecret} = require("firebase-functions/params");
const {GoogleGenAI} = require("@google/genai");
// 金鑰存在 Secret Manager,程式碼裡只有它的名稱
const geminiApiKey = defineSecret("GEMINI_API_KEY");
exports.askGemini = onCall(
// secrets:宣告這支函式要用保險箱裡的哪些金鑰,執行時才讀得到
{secrets: [geminiApiKey], enforceAppCheck: true},
async (request) => {
const question = request.data?.question;
if (typeof question !== "string" || question.length === 0 ||
question.length > 200) {
throw new HttpsError(
"invalid-argument", "問題要 1~200 字");
}
const ai = new GoogleGenAI({apiKey: geminiApiKey.value()});
let response;
try {
response = await ai.models.generateContent({
model: "gemini-3.5-flash-lite",
contents: question,
});
} catch (error) {
// 原因記在 log(例如 503 模型忙碌),App 收到明確的錯誤代碼
logger.error("Gemini 呼叫失敗",
{status: error.status, reason: error.message});
throw new HttpsError(
"unavailable", "Gemini 暫時無法回應,請稍後再試");
}
return {answer: response.text};
},
);
幾個重點寫法:
| 寫法 | 說明 |
|---|---|
| defineSecret | 宣告金鑰在保險箱裡的名稱,要跟步驟 2 存的一樣 |
| secrets: [geminiApiKey] | 只有列在這裡的函式,執行時才讀得到這把金鑰 |
| geminiApiKey.value() | 在函式執行時取出金鑰 |
| try/catch | Gemini 出錯時,App 收到 unavailable,而不是看不出原因的 INTERNAL |
官方建議的模型:
「For any new projects, use our latest models: 3.5 Flash-Lite or 3.8 Flash.」
這次用輕量的 gemini-3.5-flash-lite。
firebase deploy --only functions:askGemini
部署時,CLI 會自動讓函式有權限讀取這把金鑰:
✔ secretmanager: Granted roles/secretmanager.secretAccessor
on projects/kenkou-tw-dev/secrets/GEMINI_API_KEY
to 494602719837-compute@developer.gserviceaccount.com
App 端的呼叫方式跟昨天的 greet 一樣,
把函式名稱換成 askGemini、資料換成 question 就好。
| 情況 | 結果 |
|---|---|
| 正常呼叫 | 成功,Gemini 的回答在回應的 result.answer |
| 問題超過 200 字 | 400 INVALID_ARGUMENT:問題要 1~200 字 |
| 沒有通行證,直接用 curl 呼叫 | 401 UNAUTHENTICATED |
Gemini 的回答:
台灣夜市是融合在地美食、庶民文化與人情味的霓虹不夜城,
也是體驗台灣最接地氣的生活百寶箱。
程式碼和 App 裡,都只看得到 GEMINI_API_KEY 這個名稱,看不到金鑰本身;沒有通行證的請求也進不來:守衛和保險箱都有在運作。
補充:
實測時遇過一次 Gemini 回傳 503:
This model is currently experiencing high demand.
Spikes in demand are usually temporary. Please try again later.
模型目前需求量大,通常是暫時的,請稍後再試。還沒加 try/catch 之前,App 只收到 INTERNAL;
加上之後,App 會收到 unavailable 和說明,原因在 log 裡。
今天替鏢局補上了兩道防護:
守衛(App Check):保險箱(Secret Manager):有了守衛和保險箱,鏢局才算真正可以放心營業!
Cloud Functions for Firebase 系列,這邊告一段落~
希望我們都對於 Firebase Functions 有多一些認識!
鏢局開好了,我們就可以放心拿令牌請俠客 Google Cloud Speech-to-Text 來幫忙:
聽聽看,是否能辨識 短音節音訊「怕、踏、卡、啦」和 繞口令 !
明天實測 Cloud Speech-to-Text,替健口動一動的語音辨識找解方!
感謝有緣看到這邊的你~
希望佛菩薩也祝福你:🌟平安歡喜 自在順心🌟
南無觀世音菩薩🍀 南無地藏菩薩🏠 南無阿彌陀佛☀️