はじめに
Androidアプリに埋め込んだAPIキーは、APKを逆コンパイルすれば読めます。では「キーをLambdaの環境変数に移して、アプリはLambda経由でAPIを呼ぶ」ようにすれば安全になるのでしょうか。
結論から書くと、Lambdaを挟んだだけでは安全にはなりません。
- Lambdaの環境変数にAPIキーを置くことで、APKから直接APIキーを読まれることはなくなります。
- しかし、Lambdaを呼ぶための入り口であるAPI GatewayキーはAPKに残る
- そのため、APIキーを隠すだけでなく、「使える量に上限をつけて被害を限定する」必要がある
本記事では、以前に書いた「書籍管理アプリ(Kotlin×Google Books API)」の続きとして、APIキーをLambda+API Gatewayに逃がすことで、どのようなことが分かったかをまとめます。
こんな人に向けて書いています
- AndroidアプリにAPIキーを埋め込んでいるが、APKから読まれるのが怖い人
- AWSは触り始めたばかりで、API Gatewayの使用量プランを動かして確かめたい人
スクリーンショット内のキーは、すべてダミー値(SampleAPIKey、SampleAPIGatewayKey)に差し替えています。
| 用語 | ひとことで |
|---|---|
| Lambda | サーバーを持たずに、コードを呼ばれたときだけ動かせるサービス |
| API Gateway | Lambdaなどの前に置く、HTTPの入口。認証や流量制限を担当できる |
| 使用量プラン | API Gatewayで、APIキーごとに「秒間◯回、1日◯回まで」と上限を決める機能 |
| 429 | 「リクエストが多すぎる」を表すHTTPステータス。上限を超えるとこれが返る |
前提
1. 構成図
2. Before: APKを開いたらキーが見えた
デバッグAPK('app-debug.apk')を逆コンパイルツールであるjadxで開くと、'BuildConfig'クラスにGOOGLE_BOOKS_API_KEYが平文の文字列定数として入っていました。
キーの値はlocal.properties由来です。local.propertiesは.gitignoreに記載し、Gitには載りませんが、ビルド時にAPKへ平文で埋め込まれます。「Git管理外=安全」ではなく、今後多くのユーザに使用してもらおうとAPKを配布した際に備えて、ビルド成果物まで含めて考える必要があります。
実装と計測
1. Lambdaに逃がす
Google Books APIとの通信をLambdaに任せ、APIキーはLambdaの環境変数に置きます。アプリはLambdaの前に置いたAPI Gateway(GET /books?q=)を呼ぶだけになります。
Lambda側(Java 21)の主な設計は、次の通りです。
| 項目 | 決めたこと | 理由 |
|---|---|---|
| Googleキーの送り方 |
x-goog-api-keyヘッダー |
URLにキーが残らない |
| 上流エラー | Googleの4xx/5xx/タイムアウトは502に変換 | こちらの設定ミス(500)と区別する |
| ログ | ステータスとreasonのみ。キー・URLは出さない |
秘匿と原因調査の両立 |
| 実行ロール |
AWSLambdaBasicExecutionRoleのみ |
ログ書き込み以外の権限を与えない |
実装の詳細は、後日別記事にまとめる予定です。
2. 構成図
3. 実装
修正を行う前にコード(BookRepository.ktとApiService.kt)を確認しました。BookRepositoryは受け取ったキーをそのままApiServiceに渡すだけの単純設計であったため、隠れた依存はありませんでした。以下に変更点をまとめます。
| ファイル | 変更 |
|---|---|
ApiKeyInterceptor.kt(新規) |
全リクエストにx-api-keyヘッダーを付与 |
(ビルド設定)build.gradle.kts / local.properties
|
GATEWAY_API_KEYのbuildConfigFieldを追加(APIキーと同じ) |
ApiService.kt |
パスをvolumes→booksに変更、キーの@Queryを削除 |
RetrofitInstance.kt |
BASE_URLをAPI GatewayのURLに変更、OkHttpClientにInterceptorを追加 |
BookRepository.kt |
searchBooksからapiKey引数を削除 |
SearchActivity.kt |
BuildConfig.GOOGLE_BOOKS_API_KEYのimportと参照を削除 |
記事内でコード記述は割愛します。
プロジェクト全体のソースコードは以下のGitHubで公開しています。
4. After: アプリ側でAPIキーは消えた。でも...
Beforeと同じ手順・同じツールで、今度はリリースビルド(./gradlew assembleRelease)のAPKを開き、BuildConfigを確認しました。
| フィールド | 状態 |
|---|---|
GOOGLE_BOOKS_API_KEY |
実キー値なし |
GATEWAY_API_KEY |
値が埋め込まれている(AWS形式。AIzaではない) |
Googleのキーは消えました。でも、API Gatewayのキーは、APKから読めます。
つまり、入口は公開のままです。Lambdaを挟んで「隠せた」のは中身(Googleのキー)だけで、入口を呼ぶための鍵は、結局アプリに持たせるしかありません。
5. 使用量プランで被害を限定する
鍵が抜かれる前提なら、抜かれても困らないようにします。API Gatewayの使用量プランで、鍵ごとに使える量へ上限を付けます。
5-1. APIキーの必須化
GET /booksにAPIキー必須の設定をして、挙動を確認しました。
-
x-api-keyなし → 403 -
x-api-keyあり → 200
5-2. 使用量プランの設定
| 設定 | 値 |
|---|---|
| レート | 5リクエスト/秒 |
| バースト | 10 |
| クォータ | 1,000回/日 |
5-3. 負荷テスト
15秒間、8並列でGET /booksを連打しました(3回実施)。
| 結果 | 件数 |
|---|---|
| 200(成功) | 83〜85件(3回平均 84件) |
| 429(制限) | 平均 1,311件 |
| 合計 | 平均 1,395件 |
理論値は、バースト分(最初から使える量)と、15秒間の補充分を足した値です。
5(リクエスト/秒) × 15(秒) + 10(バースト) = 85件
実測の200は83〜85件で、**理論値とほぼ一致**しました。
理論値と実測値が完全に一致していない理由
AWSのドキュメントでは、スロットリングは「ベストエフォート」で適用されることが説明されています。API Gatewayは複数のノードで分散処理されているため、トークンの管理が1つのバケットで完結せず、数件のズレがでることがあります。今回の計測では、3回の平均が理論値と一致しているため、ほぼ正しい挙動と判断しました。
参考資料
5-4. 日次クォーターテスト
クォータを50回/日に下げて連打すると、50件が200、超過後の5件が429(Limit Exceeded)で、計55リクエストで止まりました。
つまり、鍵が抜かれても、1つの鍵で使える量には上限があるということです。「被害の最大値を設計で決められる」のが、使用量プランの価値です。
本構成の問題点
枠は共有です。 使用量プランの上限は、その鍵を使う全員で共有されます。悪意のある誰かがGatewayキーを抜いて枠を使い切ると、正規のユーザーも429になります。「被害の上限」は決められても、「被害に遭わない」わけではありません。
また、旧Googleキーは、Beforeで実キーを使っていたと確定したため、削除・再発行が必須です。
運用面では、CloudWatchのロググループ保持期間(デフォルトは無期限)と、AWS Budgetsの月額アラートも設定したいところです。
今後の展望
**認証・認可(Cognito)**で、ログイン済みのユーザーだけが呼べるようにします。JWT(期限付きの署名付きトークン)で「誰が使えるか」を決められるので、共有枠の問題に対処できます。今回は未検証です。
まとめ
- Lambdaを挟むと、Googleのキーは消せる。ただしAPI Gatewayのキーは、APKに残る
- 入口が公開のままなので、使用量プランで被害を限定する。5rps / バースト10 / 1,000回/日の設定では、15秒の持続負荷で200が83〜85件(理論値85件)に収まり、残りは429になった。
「隠す」と「被害を限定する」は別の話です。キーを隠せたと安心せず、抜かれても困らない設計かどうかを確かめるところまでやって、初めて価値が出るのだと思います。
関連記事
参考記事



