MonacaアプリやWebアプリを開発する際、外部APIを利用することが一般的になっています。たとえば、地図情報や天気予報、SNS連携など、多くの機能がAPIを通じて提供されています。しかし、これらのAPIを利用するためには、APIキーやトークンといった認証情報が必要です。

これらの情報は、API利用時のセキュリティを確保するために重要ですが、アプリケーションのコードに直接埋め込むことは非常に危険です。なぜなら、JavaScriptのバンドルやネットワークログから簡単に取得されてしまうからです。

そこで、本記事では、APIキーを安全に扱うための方法として、サーバーレスアーキテクチャを活用したリバースプロキシの利用を解説します。APIキーをフロントエンドに直接置くことなく、安全に外部APIを利用できますので、ぜひ参考にしてください。

APIキーをフロントに置く危険性

まず最初に、なぜAPIキーをフロントエンドに置いてはいけないのか、その理由を解説します。

アプリのコードからAPIキーを抜き出す

MonacaアプリやWebアプリでは、HTMLやJavaScriptを使ってコードを書きます。その際、ソースを閲覧できれば、APIキーを簡単に抜き出せてしまいます。

このキーが従量課金のAPIであった場合、悪意のあるユーザーによって不正利用される可能性があります。昨今であれば、OpenAIのAPIキーを使って、不正なLLM利用につながるケースも増えています。この場合、予期せぬ高額請求につながってしまうでしょう。

また、APIキーがデータ取得だけでなく、書き込みや削除に対応している場合には、被害が拡大する可能性があります。たとえばAWSのIAMユーザーのキーが漏洩した場合、AWSリソースの削除や改ざんが行われる恐れがあります。

こうした不正利用を防止するためには、APIキーに対する権限を適切に管理する方法もありますが、そもそもフロントエンドにキーを置かないことが最も効果的な対策です。

ネットワークログからのキー抜き出し

キーやトークンを使ってAPIアクセスを行っている場合、ネットワークログからもキーが抜き出される可能性があります。たとえば、ブラウザの開発者ツールを使って、APIリクエストのヘッダーやボディを確認できます。

HTTPSアクセスは基本的に安全ですが、Charlesのようなプロキシアプリを使えば、HTTPS通信の内容も確認できるようになります。基本的に、アプリからネットワークを利用している以上、絶対漏洩しないという保証はありません。

モバイルアプリとWebアプリの違い

モバイルアプリの場合、UIのコードは各ユーザーの端末にあります。そのため、万一APIキーが漏洩した際に、再発行してもアプリ側のコードは即座には反映されません。ユーザーがアプリの更新を行わなければならず、これは早急に広がるものではありません。社内向けのアプリであっても、告知から反映までしばらく時間がかかりますし、OSのバージョンによっては更新が行われないこともあります。

一方でWebアプリの場合は、サーバー側でUIを保持しているため、APIキーの反映が即座に行えます(PWAなどの例外はありますが)。そのため、万一の際の対応も迅速に行えます。

ただし、いずれの場合においても、APIキーをフロントエンドに置くことはセキュリティ上のリスクが高いことに変わりはありません。したがって、APIキーを安全に扱うための設計が必要です。

セキュリティの原則

絶対の安全がない以上、ゼロトラスト(Zero Trust)セキュリティの原則に従うことが重要です。つまり、信頼できないクライアントに秘密情報は渡さないのが重要です。APIキーやトークンを安易にハードコーディングしないことはもちろん、最小限の権限設定にしておくことも大事です。

また、利用に際して課金が発生するものについては、課金の上限設定を行っておき、不正利用による高額請求を防ぎましょう。

回避策として、リバースプロキシを使う

重要なキーを渡さずにAPIを利用するための方法として、リバースプロキシを利用するアプローチがあります。具体的には、以下のような設計が考えられます。

  1. クライアント側からは自分で用意したAPIエンドポイントにアクセスする
  2. 自作のAPIエンドポイントが外部APIにアクセスし、必要なデータを取得する
  3. 自作APIが取得したデータをクライアントに返す

このようにすることで、クライアント側にはAPIキーを渡さずに済みます。自作のAPIエンドポイントが外部APIへのゲートウェイとなり、APIキーを安全に管理できます。

この時、自作のAPIエンドポイントでは以下のような設計を行うことが重要です。

  • APIキーは環境変数などで安全に保管し、コード内にハードコーディングしない
  • CORS設定を適切に行い、信頼できるドメインからのアクセスのみを許可する
  • レートリミットを設定し、不正なアクセスや過剰なリクエストを防ぐ

この他、OAuthやAPI Gatewayなどの認証・認可機能を利用することで、より安全な設計が可能になります。

サーバーレスで安全に動かすには

APIエンドポイントを自作する場合、通常はサーバーを立てて行うことになります。しかし、サーバーを立てることは運用やメンテナンスの負担が増えるため、最近はサーバーレスアーキテクチャを選択するケースが増えています。

サーバーレスアーキテクチャとしては、以下のようなプラットフォームがあります。

Cloudflare Workers

Cloudflare Workersは、JavaScriptやTypeScriptで書かれたコードをエッジで実行できるサーバーレスプラットフォームです。JavaScriptのエンジンはNode.jsではなく、V8エンジンを使用しています。そのため、Node.jsとは一部のAPIが異なっている点に注意が必要です。

Cloud Functions for Firebase

Cloud Functions for Firebaseは、Googleが提供するサーバーレスプラットフォームで、Firebaseと連携して動作します。Node.jsで書かれたコードを実行でき、Firebaseのリアルタイムデータベースや認証機能と組み合わせて利用できます。

Vercel Edge Functions

Vercel Edge Functionsは、Vercelが提供するサーバーレスプラットフォームで、Next.jsと連携して動作します。Vercelのエッジネットワークを利用して、低遅延でコードを実行できます。

AWS Lambda

AWS Lambdaは、Amazonが提供するサーバーレスプラットフォームで、Node.jsやPython、Java、C#などの言語で書かれたコードを実行できます。API Gatewayと組み合わせて、RESTful APIを簡単に構築できます。

Azure Functions

Azure Functionsは、Microsoftが提供するサーバーレスプラットフォームで、C#やJavaScript、Pythonなどの言語で書かれたコードを実行できます。Azureの各種サービスと連携して、柔軟なアプリケーションを構築できます。

各サービスの比較

各サービス毎に、利用言語や実行環境、課金形態などが異なります。以下に比較表を示します。

項目 Cloudflare Workers Firebase Functions Vercel Edge Functions AWS Lambda Azure Functions
利用言語 JavaScript (Workers)、TypeScript、Rust (WASM)、Python Node.js JavaScript / TypeScript Node.js、Python、Java、Go、.NET、Ruby、Custom Runtime C#、Java、JavaScript、Python、PowerShell
無料枠 10万リクエスト/日 2万リクエスト/月 50万リクエスト/月 100万リクエスト/月 25万リクエスト/月
最大実行時間 10ms(無料枠)、最大30秒(Workers Paid) 540秒(Blaze プラン) 30秒 最大15分 230秒
デプロイサイズ制限 最大1MB 最大100MB(ソース)、最大512MB(含依存) 約1MB(制限あり) 最大50MB(.zip)または250MB(uncompressed) 1GB
特徴 高速・グローバル展開・低レイテンシ Google Cloudとの統合が強み Next.jsと親和性が高く、UIとの統合性が高い 汎用性が高く、エコシステムも豊富 Azureとの親和性が高い

利用する際には、単独で利用するのか、他のクラウドプラットフォームのサービスと組み合わせるのかによって選択肢が変わるでしょう。また、利用できる言語は異なるので、プロジェクトの要件に応じて選ぶことが重要です。

Cloudflare Workersのサンプル実装

たとえばCloudflare Workersを使って、APIキーを安全に扱うリバースプロキシの実装例を示します。OpenAIのAPIキーは、Cloudflare Workersの環境変数 OPENAI_API_KEY として設定されているものとします。

以下の場合、 /proxy エンドポイントにリクエストを送ると、OpenAIのAPIにリクエストが転送され、レスポンスが返されます。クライアント側にはAPIキーは公開せずに利用できます。

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    if (url.pathname !== "/proxy") {
      return new Response("Not found", { status: 404 });
    }

    // OpenAIのAPIエンドポイント(例: chat)
    const openaiUrl = "https://api.openai.com/v1/chat/completions";

    // 元のリクエストボディとヘッダーを転送
    const requestBody = await request.text();

    const response = await fetch(openaiUrl, {
      method: "POST",
      headers: {
        "Authorization": Bearer ${env.OPENAI_API_KEY},
        "Content-Type": "application/json"
      },
      body: requestBody
    });

    const responseBody = await response.text();

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

さらにCORS制限を行う場合には、以下のように制御を追加します。

const ALLOWED_ORIGINS = ["https://example.com"];

function getCorsHeaders(origin) {
  return {
    "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : "null",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization"
  };
}

export default {
  async fetch(request, env, ctx) {
    const origin = request.headers.get("Origin") || "";

    // CORSプリフライトリクエストの処理
    if (request.method === "OPTIONS") {
      return new Response(null, {
        status: 204,
        headers: getCorsHeaders(origin) // CORSヘッダーを追加
      });
    }

    if (new URL(request.url).pathname !== "/proxy") {
      return new Response("Not found", { status: 404 });
    }

    // OpenAI APIへ転送
    const openaiUrl = "https://api.openai.com/v1/chat/completions";
    const body = await request.text();

    const openaiResponse = await fetch(openaiUrl, {
      method: "POST",
      headers: {
        "Authorization": Bearer ${env.OPENAI_API_KEY},
        "Content-Type": "application/json"
      },
      body
    });

    const responseBody = await openaiResponse.text();
    const corsHeaders = getCorsHeaders(origin);

    return new Response(responseBody, {
      status: openaiResponse.status,
      headers: {
        ...corsHeaders,
        "Content-Type": openaiResponse.headers.get("Content-Type") || "application/json"
      }
    });
  }
};

さらにセキュアにする場合には、ユーザー毎に認証を行ってトークンを生成し、トークン毎に利用状況を管理します。

まとめ

今回はAPIキーやトークンをセキュアに扱う一歩目として、サーバーレスアーキテクチャを利用したリバースプロキシの実装方法を解説しました。APIキーをフロントエンドに置かず、サーバーレスで安全にAPIを利用することで、セキュリティリスクを大幅に低減できます。

この方法は、MonacaアプリやWebアプリに限らず、さまざまなアプリケーションで応用可能です。アプリから直接外部のAPIを利用せず、サーバーレスアーキテクチャの採用を検討してください。