個人開発でGemini APIをモバイルアプリに組み込んだとき、最初に直面する問題があります。
APIキーをアプリバイナリに直接埋め込む方法は、ある日突然、意図しない大量リクエストによる請求通知で終わります。逆アセンブルツールや通信キャプチャで、アプリからAPIキーを取り出すのはそれほど難しくありません。私も以前、テスト用にAPKへ直接埋め込んでいたキーが流出し、翌朝のコーヒーを飲みながら$300の請求通知を見た経験があります。
ここで扱うのはそのような事態を防ぐためのアーキテクチャを解説します。Firebase App Check + Cloud Functions バックエンド + 多層防御の組み合わせで、APIキーをクライアントに一切渡さずにGemini APIを安全に利用する設計を、本番で動くコードと一緒に紹介します。
なぜ直接埋め込みは危険なのか
モバイルアプリのAPIキー漏洩は、想像より簡単に起きます。
方法1: バイナリ逆アセンブル
Androidの.apkファイルはzip形式で、apktoolで簡単にリソースと文字列を抽出できます。iOS の.ipaも同様です。APIキーが文字列リテラルとしてコードに存在するだけで検出可能です。
方法2: ネットワーク通信のキャプチャ
Charles ProxyやmitmproxyでHTTPS通信を傍受し、Authorizationヘッダーを確認するだけです。SSL Pinningなしの実装は特に脆弱です。
方法3: 公開されたリポジトリ
GitHubやGitLabにキーが含まれた設定ファイルをpushしてしまうケースも多いです。
どの方法も、専門知識がなくても試せます。流出したAPIキーは即座に悪用され、あなたの請求先に課金されます。
設計するアーキテクチャの全体像
安全なアーキテクチャは以下の3層で構成します。
第1層: Firebase App Check(デバイス認証)
本物のiOS/Androidデバイスからのリクエストかどうかを検証します。ボット・エミュレーター・改ざんされたアプリからのリクエストをブロックします。
第2層: Cloud Functions バックエンド(APIキー保護)
Gemini APIキーはバックエンドにのみ存在します。クライアントはApp Checkトークンをバックエンドに送り、バックエンドがGemini APIを呼び出します。
第3層: レートリミット + 異常検知(コスト制御)
ユーザーごとのAPIコール制限と、異常な使用パターンの検知・自動ブロックを実装します。
Step 1: Firebase プロジェクトのセットアップ
まず、FirebaseプロジェクトにApp Checkを有効化します。
# Firebase CLIのインストール
npm install -g firebase-tools
# Firebaseにログイン
firebase login
# プロジェクト初期化(既存プロジェクトの場合はスキップ)
firebase init functions
firebase.json の設定:
{
"functions": {
"source": "functions",
"runtime": "nodejs20"
}
}
Step 2: Cloud Functions バックエンドの実装
バックエンドのコアとなるCloud Functionsを実装します。App Checkトークンの検証・レートリミット・Gemini API呼び出しを一つの関数にまとめます。
// functions/index.js
const { onRequest } = require("firebase-functions/v2/https");
const { initializeApp } = require("firebase-admin/app");
const { getFirestore, FieldValue } = require("firebase-admin/firestore");
const { GoogleGenerativeAI } = require("@google/generative-ai");
initializeApp();
const db = getFirestore();
// Gemini APIクライアント(サーバーサイドのみ)
const genai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
// レートリミット設定
const RATE_LIMIT = {
requestsPerMinute: 10, // 1分あたりの最大リクエスト数
requestsPerDay: 100, // 1日あたりの最大リクエスト数
maxTokensPerRequest: 2000, // 1リクエストの最大トークン数
};
/**
* App Checkトークンを検証する
* @param {string} appCheckToken - クライアントから送られたApp Checkトークン
* @returns {boolean} 検証結果
*/
async function verifyAppCheck(appCheckToken) {
if (!appCheckToken) {
return false;
}
try {
const { getAppCheck } = require("firebase-admin/app-check");
await getAppCheck().verifyToken(appCheckToken);
return true;
} catch (error) {
console.error("App Check verification failed:", error.message);
return false;
}
}
/**
* ユーザーのレートリミットを確認・更新する
* @param {string} userId - ユーザーID(匿名UID)
* @returns {{ allowed: boolean, reason: string }}
*/
async function checkRateLimit(userId) {
const now = Date.now();
const minuteAgo = now - 60 * 1000;
const dayAgo = now - 24 * 60 * 60 * 1000;
const userRef = db.collection("ratelimits").doc(userId);
return await db.runTransaction(async (transaction) => {
const userDoc = await transaction.get(userRef);
const data = userDoc.exists ? userDoc.data() : { requests: [] };
// タイムスタンプをフィルタリング(期限切れを削除)
const requests = (data.requests || []).filter(
(ts) => ts > dayAgo
);
// 1分間のリクエスト数を確認
const lastMinuteCount = requests.filter((ts) => ts > minuteAgo).length;
if (lastMinuteCount >= RATE_LIMIT.requestsPerMinute) {
return {
allowed: false,
reason: `レートリミット超過: 1分間に${RATE_LIMIT.requestsPerMinute}リクエストまでです`,
};
}
// 1日のリクエスト数を確認
if (requests.length >= RATE_LIMIT.requestsPerDay) {
return {
allowed: false,
reason: `デイリーリミット超過: 1日に${RATE_LIMIT.requestsPerDay}リクエストまでです`,
};
}
// リクエストを記録
requests.push(now);
transaction.set(userRef, {
requests,
lastRequest: now,
updatedAt: FieldValue.serverTimestamp(),
});
return { allowed: true, reason: "" };
});
}
/**
* 異常検知: 短時間に同じプロンプトが繰り返されていないか確認
* @param {string} userId
* @param {string} promptHash - プロンプトのハッシュ
*/
async function detectAnomaly(userId, promptHash) {
const anomalyRef = db
.collection("anomalydetection")
.doc(`${userId}_${promptHash}`);
const doc = await anomalyRef.get();
if (doc.exists) {
const data = doc.data();
const count = (data.count || 0) + 1;
// 同じプロンプトが5分以内に10回以上送られた場合は異常とみなす
if (count >= 10 && Date.now() - data.firstSeen < 5 * 60 * 1000) {
await anomalyRef.set({
count,
firstSeen: data.firstSeen,
lastSeen: Date.now(),
blocked: true,
});
return true; // 異常検知
}
await anomalyRef.update({ count, lastSeen: Date.now() });
} else {
await anomalyRef.set({
count: 1,
firstSeen: Date.now(),
lastSeen: Date.now(),
blocked: false,
});
}
return false;
}
/**
* メインのGemini API Proxyエンドポイント
* クライアントからApp Checkトークンを受け取り、検証後にGemini APIを呼び出す
*/
exports.geminiProxy = onRequest(
{
region: "asia-northeast1", // 東京リージョン
memory: "256MiB",
timeoutSeconds: 30,
secrets: ["GEMINI_API_KEY"],
},
async (req, res) => {
// CORSヘッダー設定
res.set("Access-Control-Allow-Origin", "*");
if (req.method === "OPTIONS") {
res.set("Access-Control-Allow-Methods", "POST");
res.set("Access-Control-Allow-Headers", "Content-Type, X-Firebase-AppCheck");
return res.status(204).send("");
}
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
// Step 1: App Checkトークンの検証
const appCheckToken = req.headers["x-firebase-appcheck"];
const isValid = await verifyAppCheck(appCheckToken);
if (!isValid) {
console.warn("App Check validation failed");
return res.status(403).json({
error: "unauthorized",
message: "認証に失敗しました",
});
}
// Step 2: リクエストボディの検証
const { prompt, userId, model = "gemini-2.5-flash" } = req.body;
if (!prompt || !userId) {
return res.status(400).json({ error: "prompt と userId は必須です" });
}
// プロンプトの長さ制限(クライアントでも確認するが、サーバーでも必ず確認)
if (prompt.length > 4000) {
return res.status(400).json({ error: "プロンプトが長すぎます(最大4000文字)" });
}
// Step 3: レートリミットの確認
const rateLimitResult = await checkRateLimit(userId);
if (!rateLimitResult.allowed) {
return res.status(429).json({
error: "rate_limit_exceeded",
message: rateLimitResult.reason,
});
}
// Step 4: 異常検知
const crypto = require("crypto");
const promptHash = crypto
.createHash("md5")
.update(prompt)
.digest("hex");
const isAnomalous = await detectAnomaly(userId, promptHash);
if (isAnomalous) {
return res.status(429).json({
error: "anomaly_detected",
message: "異常なリクエストパターンを検知しました",
});
}
// Step 5: Gemini API呼び出し(サーバーサイドのみ)
const geminiModel = genai.getGenerativeModel({
model,
generationConfig: {
maxOutputTokens: RATE_LIMIT.maxTokensPerRequest,
temperature: 0.7,
},
});
const result = await geminiModel.generateContent(prompt);
const text = result.response.text();
// 使用量をログに記録(コスト追跡用)
await db.collection("usage_logs").add({
userId,
model,
promptLength: prompt.length,
responseLength: text.length,
inputTokens: result.response.usageMetadata?.promptTokenCount || 0,
outputTokens: result.response.usageMetadata?.candidatesTokenCount || 0,
timestamp: FieldValue.serverTimestamp(),
});
return res.status(200).json({ text });
} catch (error) {
console.error("Gemini API error:", error);
return res.status(500).json({
error: "internal_error",
message: "サーバーエラーが発生しました",
});
}
}
);
期待する動作:
- 正常なiOS/Androidデバイスからのリクエスト → 200 OK + Geminiの応答テキスト
- App Checkトークンなし/無効 → 403 Forbidden
- レートリミット超過 → 429 Too Many Requests
- APIキーはクライアントに一切返却されない
Step 3: iOS(SwiftUI)のApp Check統合
// ContentView.swift
import SwiftUI
import FirebaseCore
import FirebaseAppCheck
// AppDelegateでFirebaseを初期化
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
FirebaseApp.configure()
// App Checkプロバイダーの設定
// 本番環境: DeviceCheckAttestation
// 開発環境: DebugAttestation(デバッグトークンを使用)
#if DEBUG
let providerFactory = AppCheckDebugProviderFactory()
#else
let providerFactory = DeviceCheckProviderFactory()
#endif
AppCheck.setAppCheckProviderFactory(providerFactory)
return true
}
}
// GeminiService.swift - バックエンドを呼び出すサービスクラス
class GeminiService: ObservableObject {
// Gemini APIキーはここに存在しない
// バックエンドのURL(環境変数から取得することを推奨)
private let backendURL = "https://asia-northeast1-YOUR_PROJECT.cloudfunctions.net/geminiProxy"
func generateText(prompt: String, userId: String) async throws -> String {
// Step 1: App Checkトークンを取得
let appCheckToken: AppCheckToken
do {
appCheckToken = try await AppCheck.appCheck().token(forcingRefresh: false)
} catch {
throw GeminiError.appCheckFailed(error.localizedDescription)
}
// Step 2: バックエンドへリクエスト送信
guard let url = URL(string: backendURL) else {
throw GeminiError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// App Checkトークンをヘッダーに付与
request.setValue(appCheckToken.token, forHTTPHeaderField: "X-Firebase-AppCheck")
let body: [String: Any] = [
"prompt": prompt,
"userId": userId,
"model": "gemini-2.5-flash"
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
// Step 3: レスポンスの処理
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw GeminiError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
let decoded = try JSONDecoder().decode(GeminiResponse.self, from: data)
return decoded.text
case 403:
throw GeminiError.unauthorized
case 429:
throw GeminiError.rateLimitExceeded
default:
throw GeminiError.serverError(httpResponse.statusCode)
}
}
}
// エラー定義
enum GeminiError: LocalizedError {
case appCheckFailed(String)
case invalidURL
case invalidResponse
case unauthorized
case rateLimitExceeded
case serverError(Int)
var errorDescription: String? {
switch self {
case .appCheckFailed(let reason):
return "デバイス認証に失敗しました: \(reason)"
case .rateLimitExceeded:
return "リクエスト数の上限に達しました。しばらく待ってから再試行してください"
case .serverError(let code):
return "サーバーエラー (\(code))"
default:
return "エラーが発生しました"
}
}
}
struct GeminiResponse: Codable {
let text: String
}
Step 4: Android(Kotlin + Jetpack Compose)の実装
// GeminiRepository.kt
class GeminiRepository(private val context: Context) {
// APIキーはここに存在しない
private val backendUrl = "https://asia-northeast1-YOUR_PROJECT.cloudfunctions.net/geminiProxy"
private val httpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
suspend fun generateText(prompt: String, userId: String): Result<String> {
return withContext(Dispatchers.IO) {
try {
// Step 1: App Checkトークンを取得
val appCheckToken = suspendCancellableCoroutine<String> { continuation ->
FirebaseAppCheck.getInstance()
.getAppCheckToken(false)
.addOnSuccessListener { token ->
continuation.resume(token.token)
}
.addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
}
// Step 2: バックエンドへリクエスト送信
val requestBody = JSONObject().apply {
put("prompt", prompt)
put("userId", userId)
put("model", "gemini-2.5-flash")
}.toString()
val request = Request.Builder()
.url(backendUrl)
.post(requestBody.toRequestBody("application/json".toMediaType()))
.addHeader("X-Firebase-AppCheck", appCheckToken)
.build()
val response = httpClient.newCall(request).execute()
when (response.code) {
200 -> {
val body = response.body?.string() ?: throw IOException("空のレスポンス")
val json = JSONObject(body)
Result.success(json.getString("text"))
}
403 -> Result.failure(SecurityException("デバイス認証に失敗しました"))
429 -> Result.failure(RateLimitException("リクエスト上限に達しました"))
else -> Result.failure(IOException("サーバーエラー: ${response.code}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
}
class RateLimitException(message: String) : Exception(message)
よくある落とし穴と対策
実際の実装で詰まりやすいポイントを3つ紹介します。
落とし穴1: デバッグビルドでApp Checkが動かない
本番のApp Check(DeviceCheck/Play Integrity)は実機のみ動作します。シミュレーターや開発環境ではAppCheckDebugProviderFactoryを使い、Firebaseコンソールでデバッグトークンを登録する必要があります。
iOS:
// Info.plistに追記(開発環境のみ)
// FirebaseAppCheckDebugToken: "YOUR_DEBUG_TOKEN"
Android:google-services.jsonのあるdebug/フォルダにデバッグ設定を分離します。
落とし穴2: App Checkトークンの有効期限
App Checkトークンには有効期限があります(通常1時間)。クライアント側でトークンのキャッシュ管理を怠ると、有効期限切れで403エラーが頻発します。FirebaseのSDKが自動的にリフレッシュしますが、forcingRefresh: falseにしておくとキャッシュが使われます。
// トークンが期限切れになった場合のリトライ処理
func generateWithRetry(prompt: String, userId: String, retryCount: Int = 0) async throws -> String {
do {
return try await generateText(prompt: prompt, userId: userId)
} catch GeminiError.unauthorized where retryCount < 1 {
// 401/403の場合はトークンを強制リフレッシュして1度だけリトライ
_ = try await AppCheck.appCheck().token(forcingRefresh: true)
return try await generateWithRetry(prompt: prompt, userId: userId, retryCount: retryCount + 1)
}
}
落とし穴3: Cloud FunctionsのコールドスタートでUX劣化
Cloud Functionsはアイドル後の初回呼び出しに500ms〜2秒かかることがあります(コールドスタート)。ユーザーに対してローディングインジケーターを表示する、あるいはFirebase Cloud Functions Minimum Instancesを1に設定することで軽減できます(コスト増加に注意)。
// コールドスタート軽減策(有料プランが必要)
exports.geminiProxy = onRequest(
{
minInstances: 1, // 常時1インスタンス起動
// ... 他の設定
},
async (req, res) => { /* ... */ }
);
コスト追跡と予算アラートの設定
バックエンドを通じてAPIを呼び出す構成なら、Google Cloudの予算アラートを活用できます。
Firebaseコンソール → プロジェクト設定 → 使用量と請求 → 予算アラートで、月間$10を超えたらメール通知するよう設定することをおすすめします。また、先ほどのコードでusage_logsコレクションに書き込んでいるデータを使って、BigQueryやLooker Studioでダッシュボードを作ると、ユーザーごとの使用量が可視化できます。
セキュリティとコスト管理の両面から参考になる一冊です。
個人開発者の視点から(実体験メモ)
全体を振り返って:今日から始める3ステップ
Gemini APIをモバイルアプリに組み込む際の安全な設計は、決して難しくありません。まずgeminiProxy Cloud Functionをデプロイし、次にiOS/AndroidアプリにFirebase App CheckのSDKを追加し、最後にAPIキーをアプリから削除する——この順番で進めるのが最もスムーズです。
既にGemini APIをモバイルアプリで使い始めている方は、Gemini APIを使ったSwiftUI本番アプリの実装ガイドやモバイルアプリへのAI機能統合マスターガイドもあわせて参考にしてみてください。APIキーの安全な管理は、個人開発のAIアプリを長期運用するための最初の関門です。ぜひ今日中に試してみてください。