fetch() で API キーを安全に扱う:JS に書かず PHP で代理(PHP 中継ファイル)

JavaScript の fetch() で外部 API を呼び出す場面は多くありますが、その際に API キーをどこに、どう書くかは重要なポイントです。以下では、フロントエンドで API キーを使ってはいけない理由と安全に API キーを扱う方法(PHP プロキシ/PHP 中継ファイル)をサンプルとともに掲載しています。

作成日:2025年05月10日

危険な実装パターン(APIキーをJSに書く)

例えば以下のように、API キーを埋め込んで fetch() を実行した場合:

const API_KEY = 'abc123def456';

fetch('https://example.com/api/posts', {
  headers: {
    'X-API-Key': API_KEY  // API キーを JS に記述
  }
})
  .then(response => response.json())
  .then(data => console.log(data));

上記のコードでは、API キーは HTTP リクエストヘッダーに含まれているため、ブラウザの開発者ツールのネットワークタブで簡単に見えてしまうため、セキュリティ上極めて危険です。

たとえソースコードを minify などしても、リクエスト内容自体は隠せません。悪意のある第三者にキーを盗まれ、不正利用されるリスクがあります。

よくある誤解(APIキー取得をJSで行う)

以下は一見安全に見えますが、実は危険な実装例です。

別ファイルに API キーを定義して、fetch() を使って API キーを取得します。

fetch('api/apikeys.php')
  .then(res => res.json())
  .then(({ apiKey }) => {
    return fetch('https://example.com/api/posts', {
      headers: {
        'X-API-Key': apiKey
      }
    });
  });

この例(apikeys.php)では API キーを JSON で返しています。

<?php
header('Content-Type: application/json');
echo json_encode([
  'apiKey' => 'abc123def456'
]);

一見「JavaScript は直接キーを持っていない」ように見えますが、結局のところ API キーをフロントエンドに渡している(JavaScript 経由で通信している)時点で保護されていないのと同じです。通信を盗聴・観察されれば、API キーはすぐに第三者の手に渡ります。

また、上記のコードは「一見保護されてるように見える」分だけ別の意味で危険性が高いとも言えます。

安全な代替手段:PHP プロキシを使う

ではどうすればいいのかというと、APIキーを使う処理をすべてサーバー側(PHPなど)に集約し、フロントエンドには一切渡さないことです。

ここでいう「PHPプロキシ」とは、JavaScriptから直接外部APIにアクセスするのではなく、サーバー上の PHP ファイルを中継点(プロキシ)として利用し、その中で API キーを使って外部 APIへ リクエストを送る仕組みを指します。

この方法を使えば、APIキーなどの機密情報を外部に漏らすことなく、安全にAPIと通信できます。

[ブラウザ]
   │
   ├─ fetch() リクエスト(APIキーなし)
   ↓
[PHP 中継ファイル]
   ├─ 内部的に APIキーを使って cURL で外部APIへアクセス(APIキーはここでのみ使用)
   ↓
[外部 API]
  • API キーはサーバーサイド(PHP)だけで管理し、クライアントには一切渡さない
  • PHP はサーバー上で実行され、ソースコードは外部から閲覧できない
  • そのため、PHP に記述した API キーは漏洩のリスクが大幅に下がる

実装時のポイント

  • クライアントから渡された URL やパラメータは必ずバリデーション・サニタイズする
  • API キーは、環境変数や安全な設定ファイルから読み込む
  • cURLfile_get_contents などで外部 API にアクセスし、結果を返す
  • エラーが発生した場合は、適切な HTTP ステータスとエラーメッセージを返す

PHP によるプロキシ実装

以下がサンプルのファイル構成です。

project-root/
  ├── api/
  │   ├── config/
  │   │   ├── .htaccess        # 直接アクセスを禁止
  │   │   └── api-config.php   # API キーを定義
  │   └── fetch-posts.php      # JS(ブラウザ)がリクエストする中継用 PHP ファイル
  ├── index.html               # フロントエンドのテスト用 HTML ファイル(fetch-posts.js を読み込む)
  └── js/
      └── fetch-posts.js       # フロントエンドの JS
  • api/config/.htaccess: api-config.php への直接アクセスを禁止するファイル
  • api/config/api-config.php:API キー(や設定)を定義する設定ファイル
  • api/fetch-posts.php :クライアントからのリクエストを受け、API に安全に中継する PHP ファイル
  • index.html :ローカルでのテスト用ページ(js/fetch-posts.js を読み込む)
  • js/fetch-posts.js:フロントエンドの JavaScript。fetch('api/fetch-posts.php') を実行

API キーをサーバーサイドに定義

以下は API キーを定数として定義する PHP ファイル(api-config.php)の例です。

<?php
// API キーを定数として定義(実際には複雑な値を指定します)
define('API_KEY', 'abc123def456');

このファイルには API キーのみを記述します。Git を使用している場合は、Git には含めないように .gitignore に追加します。

.htaccess でPHPファイルの直接アクセスを防ぐ

api-config.php への直接アクセスをブロックするための .htaccess を作成します。

Order deny,allow
Deny from all

Apache 2.4 以降であれば以下のように異なります。

<RequireAll>
  Require all denied
</RequireAll>

Apache 2.2 と 2.4 の両方に対応したい場合は、以下のようなバージョンチェック付きの記述が可能です。

<IfModule mod_authz_core.c>
  Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
  Order allow,deny
  Deny from all
</IfModule>

mod_authz_core.c は Apache 2.4 以降で導入されたモジュールです。したがって、Apache が mod_authz_core を読み込んでいれば、その環境は Apache 2.4 以降である可能性が高いです。

<IfModule mod_authz_core.c> は、そのモジュールが有効かどうかを条件分岐に使うことができます(Apache のバージョン判定ではなくモジュールの有無の判定になります)。

PHP 中継ファイル

以下が PHP 中継ファイル(fetch-posts.php)です。

フロントエンドの fetch() は直接 API にアクセスするのではなく、このファイルにアクセスし、送信先 API の URL を GET パラメータとして渡します。そしてこのファイルでは受け取った URL パラメータをもとに cURL でリクエストを送信します。

API キーの読み込みではファイル存在チェックと定数定義チェックを行い、API キーが取得できない場合は、http_response_code() でステータスコードを 500 に設定し、JSON 形式のエラーメッセージを返します。

この例では、セキュリティ上の観点からフロントエンドでは URL を渡さず、「エンドポイント名」を指定し、PHP 側でそれに応じた 送信先 URL を組み立てるようにします。

そのため、クエリパラメータから endpoint を取得し、指定されたエンドポイント名が $endpoints に存在しなければ、無効としています。これにより、送信先がユーザー入力に依存しないため、安全性が高くなります(パラメータ改ざんやリダイレクト攻撃対策)。

そして、cURL を使って API キーをカスタムヘッダーに含めてリクエストを送信します。

レスポンスは JSON として返すのでヘッダー(Content-Type: application/json)を指定します。

cURL のエラーハンドリングでは、$response が false の場合は通信自体が失敗しているので、ステータスコードを 500 に設定して cURL エラーとして返します。

$response が false 以外の場合は、curl_getinfo で取得したステータスコード($httpCode)をチェックし、ステータスコードが 200 番台以外であれば HTTP レベルでエラーとみなしてエラーを返し、200 番台であれば(成功の場合)は API からの生レスポンスをそのまま返します。

<?php
// API 設定ファイルの読み込みパス
$config_path = __DIR__ . '/config/api-config.php';

// 設定ファイルの存在チェック
if (!file_exists($config_path)) {
  http_response_code(500);
  header('Content-Type: application/json');
  echo json_encode(['error' => 'API 設定ファイルが見つかりません']);
  exit;
}

// 設定ファイルの読み込み
require_once $config_path;

// API_KEY 定義チェック
if (!defined('API_KEY')) {
  http_response_code(500);
  header('Content-Type: application/json');
  echo json_encode(['error' => 'API キーが定義されていません']);
  exit;
}

// クライアントからのリクエストで許可する「エンドポイント名」と、対応する送信先 URL を定義
// ここに登録されていない名前は受け付けない(ホワイトリスト方式で安全性を確保)
$endpoints = [
  'posts' => 'https://example.com/wp-json/my-theme-api/v1/posts',
];

// クエリパラメータから 'endpoint' を取得(存在しない場合は空文字に)
$endpointKey = $_GET['endpoint'] ?? '';

// 指定されたエンドポイント名が $endpoints に存在しなければ、無効として 400 Bad Request を返す
if (!isset($endpoints[$endpointKey])) {
  http_response_code(400);
  echo json_encode(['error' => '無効なエンドポイント']);
  exit;
}

// 有効なエンドポイント名の場合、対応する送信先 URL を取得
$baseUrl = $endpoints[$endpointKey];

// 他のクエリパラメータ(_fields, per_page など)を抽出して追加
$queryParams = $_GET;
unset($queryParams['endpoint']); // endpoint キーは除外

// API リクエストに指定する URL
$url = $baseUrl . '?' . http_build_query($queryParams);

// cURL を使用して外部 API にリクエストを送信
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // レスポンスを文字列として取得
curl_setopt($ch, CURLOPT_TIMEOUT, 10); // タイムアウト設定(秒)
curl_setopt($ch, CURLOPT_HTTPHEADER, [
  'X-API-Key: ' . API_KEY,              // API キーをカスタムヘッダーに含める
  'Content-Type: application/json',
]);

$response = curl_exec($ch);                        // 実行
$curlError = curl_error($ch);                      // cURL エラー内容(失敗時)
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); // HTTPステータスコード取得
curl_close($ch);                                   // cURLセッションを終了

// JSON としてレスポンスを返す
header('Content-Type: application/json');

// エラーハンドリングとレスポンス出力
if ($response === false) {
  // 通信自体が失敗した場合(ネットワークエラー等)
  http_response_code(500);
  echo json_encode(["error" => "cURL エラー: $curlError"]);
} else {
  // ステータスコード($httpCode)をチェック
  if ($httpCode < 200 || $httpCode >= 400) {
    // HTTP レベルでエラーとみなすステータスコードの場合
    http_response_code($httpCode);
    echo json_encode(["error" => "HTTP エラーまたは予期しないステータス: $httpCode"]);
  } else {
    // 成功
    http_response_code($httpCode);
    // API からの生レスポンス(JSON 形式)をそのまま返す(必要に応じて整形可能)
    echo $response;
  }
}

この例の場合、API レスポンスが JSON 形式であることを前提としています。

そのため、成功時にはそのまま(JSON 形式で)返しています。また、エラーも JSON 形式で返すことで(構造化できるため)詳細なエラー情報を伝えることができます。

必要に応じて Content-Type を確認し、検査・整形するなどの対応をします。

JavaScript からの安全な呼び出し方法

以下はフロントエンドの fetch() です。

fetch() のリクエストを直接 API に送る代わりに、中継用の PHP ファイル(fetch-posts.php)を経由してリクエストを送信します。

フロントエンドでは URL を渡さず、必須のパラメータ endpoint にエンドポイント名(posts)を指定し、PHP 側でそれに応じた送信先 URL を組み立てます。

以下は WordPress REST API のカスタムエンドポイントへのアクセスで API キーが要求されるという仕様を前提にしています。指定するパラメータなどは使用する API に合わせて変更(設定)します。

// クエリパラメータを構築(url は渡さない)
const params = new URLSearchParams({
  endpoint: "posts", // 中継ファイルで検証に使うエンドポイント名(必須)→ posts を指定
  _fields: "title,link", // WordPress REST API 特有のパラメータ(必要なフィールドのみ取得)
  per_page: 5, // WordPress REST API 特有のパラメータ(1ページあたりの投稿数)
}).toString();

// PHP 中継ファイルに上記で設定したパラメータ(params)を渡す( fetch() に指定する URL)
const proxyUrl = `api/fetch-posts.php?${params}`;

// fetch() で中継ファイルにリクエストを送信
fetch(proxyUrl, {
  method: "GET",
})
  .then(async (response) => {
    // レスポンスヘッダーから Content-Type を取得
    const contentType = response.headers.get("Content-Type") || "";
    let errorData = null;
    // レスポンスが失敗している(ステータスコードが 200 番台以外)
    if (!response.ok) {
      if (contentType.includes("application/json")) {
        errorData = await response.json(); // JSON形式のエラーデータを取得
      } else {
        const text = await response.text(); // テキスト形式の場合
        errorData = { error: text };
      }
      // エラー内容を例外として投げる(catch で受ける)
      throw new Error(errorData.error || `HTTPエラー: ${response.status}`);
    }
    // 成功時は JSON データをパースして返す
    return response.json();
  })
  .then((posts) => {
    // 投稿データをコンソールに出力(実際の処理に応じて変更可能)
    console.log(posts);
  })
  .catch((error) => {
    console.warn("投稿の取得エラー:", error);
  });
中継ファイルが返すエラーメッセージ

この例の中継ファイル(fetch-posts.php)は、エラーが発生した場合には基本的に JSON 形式でエラーメッセージを返すように設計しています。

そのため、フロントエンド側ではレスポンスがエラー(ステータスコードが 200 番台以外)のときに、まず Content-Type ヘッダーを確認して、返されたエラーが JSON 形式かプレーンテキストかを判別します。

response.json() や response.text() はいずれも Promise を返す非同期関数なので、await を使って完了を待つ必要があるので、then() のコールバック関数には async を指定します(15行目)。

JSON 形式のエラーメッセージはパースして内容を抽出し、テキスト形式であればそのまま表示することで、ユーザーや開発者が原因を特定しやすくなります。

エラーがテキスト(HTML)の場合

例えば、fetch() に指定された中継ファイル(fetch-posts.php)が存在しないと 404 エラーになり、サーバー(Apache など)が自動的に「404ページ(HTML)」を返します。

その場合、const text = await response.text() の text には HTML がそのまま入って、catch に届くので .catch() で HTML が表示されてしまいます(この場合はコンソールに出力)。

エラーの場合に HTML を出力したくない場合は、例えば、以下のようにレスポンスが HTML だったら内容を表示せずステータスだけ出力する(返す)方法があります。

if (!response.ok) {
  if (contentType.includes("application/json")) {
    errorData = await response.json();
  } else {
    // テキスト形式(HTML など)のエラーの場合はステータスコードのみを返す
    errorData = { error: `HTTPエラー: ${response.status}` };
  }
  throw new Error(errorData.error);
}

または、.catch() ブロックでエラーメッセージを判定して HTML の可能性のある場合は、単にメッセージを表示するなどが考えられます。但し、必ずしもすべての HTML レスポンスが <!DOCTYPE> を含むとは限らないのであくまで簡易的なチェックです。

.catch((error) => {
  const message = error.message;
  // エラーメッセージが HTML の場合
  if (message && message.startsWith("<!DOCTYPE ")) {
    console.warn(
      "投稿の取得エラー: HTMLレスポンスが返されました(パス誤りなど)"
    );
  } else {
    console.warn("投稿の取得エラー:", error);
  }
});

POST バージョン(セキュリティ強化版)

以下は PHP 中継ファイル(fetch-posts.php)で url や params, method を POST で受け取るようにしたバージョンです。POST メソッド使用しているので URL 長制限やパラメータ漏れを回避でき、API へのリクエストは GET/POST 両方に対応できる構成になっています。

また、JavaScript 側でリクエスト先の URL を指定できるようにしているので、送信元やリクエスト先の URL 検証も追加して不正アクセスで中継 API が使われないようにセキュリティを強化しています。

<?php
// API 設定ファイルの読み込みパス
$config_path = __DIR__ . '/config/api-config.php';

// 設定ファイルの存在チェック
if (!file_exists($config_path)) {
  http_response_code(500);
  header('Content-Type: application/json');
  echo json_encode(['error' => 'API 設定ファイルが見つかりません']);
  exit;
}

// 設定ファイルの読み込み
require_once $config_path;

// API_KEY 定義チェック
if (!defined('API_KEY')) {
  http_response_code(500);
  header('Content-Type: application/json');
  echo json_encode(['error' => 'API キーが定義されていません']);
  exit;
}

// JSON を返すヘッダーを設定
header('Content-Type: application/json');

// 許可する送信元
$allowedOrigins = [
  'https://mydomain.com',
  'http://mydomain.localhost:5501',
];

// リクエスト元のオリジンを取得
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';

// 許可された送信元かを確認し、CORS用ヘッダーを動的に設定
if (in_array($origin, $allowedOrigins, true)) {
  // 安全なオリジンにだけヘッダーを返す
  header('Access-Control-Allow-Origin: ' . $origin);
} else {
  // 不正なオリジンからのアクセスは拒否
  http_response_code(403);
  echo json_encode(['error' => '許可されていない送信元です']);
  exit;
}

// メソッドが POST であることを確認
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  http_response_code(405); // Method Not Allowed
  echo json_encode(['error' => 'POSTメソッドを使用してください']);
  exit;
}

// JSON形式のリクエストボディを取得し、連想配列としてデコード
$input = json_decode(file_get_contents('php://input'), true);

// JSONエラーが発生、または配列でない場合
if (json_last_error() !== JSON_ERROR_NONE || !is_array($input)) {
  http_response_code(400);
  echo json_encode(['error' => '無効なJSONです: ' . json_last_error_msg()]);
  exit;
}

// リクエストパラメータを取得( NULL 合体演算子を使って、パラメータが未定義の場合でも安全に取得)
$url = $input['url'] ?? '';
$method = strtoupper($input['method'] ?? 'GET');  // デフォルトは GET
$params = $input['params'] ?? null;

// HTTP メソッドのホワイトリスト化(許可するメソッドを制限)
$allowedMethods = ['GET', 'POST', 'PUT', 'PATCH'];
if (!in_array($method, $allowedMethods, true)) {
  http_response_code(405);
  echo json_encode(['error' => '許可されていないHTTPメソッドです']);
  exit;
}

// URLが未指定の場合はエラー
if (empty($url)) {
  http_response_code(400);
  echo json_encode(['error' => 'URLが指定されていません']);
  exit;
}

// URLのホワイトリストチェック(ホストとパス)
$parsedUrl = parse_url($url);

// スキーム(http/https)を検証(本番環境では https のみに限定)
if (!in_array($parsedUrl['scheme'] ?? '', ['http', 'https'], true)) {
  http_response_code(403);
  echo json_encode(['error' => '無効なURLスキームです']);
  exit;
}

// 許可するホストとパス
$allowedHosts = ['example.com', 'www.example.com'];
$allowedPathPrefix = '/wp/wp-json/';

// 開発時のログ(本番環境では削除)
error_log($parsedUrl['path'] ?? '(パスなし)');  // 例:/wp/wp-json/wp/v2/posts

// ホストとパスの検証
if ( !in_array($parsedUrl['host'] ?? '', $allowedHosts, true) || strpos($parsedUrl['path'] ?? '', $allowedPathPrefix) !== 0) {
  http_response_code(403);  // Forbidden
  echo json_encode(['error' => '許可されていないURLです']);
  exit;
}

// GETの場合はparamsをクエリとしてURLに追加
if ($method === 'GET' && is_array($params)) {
  $queryString = http_build_query($params);
  $url .= (strpos($url, '?') === false ? '?' : '&') . $queryString;
}

// cURL 初期化とオプション
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10); // タイムアウト設定(秒)
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); // HTTPメソッドを指定
curl_setopt($ch, CURLOPT_HTTPHEADER, [
  'X-API-Key: ' . API_KEY, // APIキーをカスタムヘッダーに指定
  'Content-Type: application/json',
]);

// POST/PUT/PATCH などで、params があれば JSON(POST データ)として送信
if (in_array($method, ['POST', 'PUT', 'PATCH'], true) && is_array($params)) {
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
}

// リクエストを実行し、レスポンスとHTTPステータスを取得
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// エラーハンドリングとレスポンス出力
if ($response === false) {
  // 通信自体が失敗した場合(ネットワークエラー等)
  http_response_code(500);
  echo json_encode(["error" => "cURL エラー: $curlError"]);
} else {
  // ステータスコード($httpCode)をチェック
  if ($httpCode < 200 || $httpCode >= 400) {
    // HTTP レベルでエラーとみなすステータスコードの場合
    http_response_code($httpCode);
    echo json_encode(["error" => "HTTP エラーまたは予期しないステータス: $httpCode"]);
  } else {
    // 成功
    http_response_code($httpCode);
    // API からの生レスポンス(JSON 形式)をそのまま返す(必要に応じて整形可能)
    echo $response;
  }
}

送信元のチェック

送信元のチェックには $_SERVER['HTTP_ORIGIN'] を使用しますが、これはブラウザが送信するものであり、開発者ツールやスクリプトで偽装可能なため、完全な信頼はできません。

PHP のレスポンスで Access-Control-Allow-Origin ヘッダー(CORS ヘッダー)を指定することで、ブラウザが同一オリジンポリシーに従って、許可されたオリジン以外からの JavaScript からのアクセスをブロックするようになります。

但し、これはブラウザによる制限であり、curl や他のサーバーサイドスクリプトなどからのアクセスを防ぐことはできません。したがって、CORS は完全なセキュリティ対策ではなく、あくまでブラウザを対象とした制御手段です(サーバー自体がアクセスを拒否するわけではありません)。

データは php://input を使って取得

データは JSON 形式で受け取るため、$_POST が使えないので php://input を使用して JSON 形式のリクエストボディを取得し、連想配列に変換して変数 $input に格納しています。

送信先のチェック

$input(取得したデータ)からリクエストパラメータの送信先 API の URL とパスを取り出して、ホワイトリスト方式(許可する送信先ドメインをあらかじめ定める方法)で検証します。

パスの検証は省略すれば実装がシンプルになりますが、その場合、許可されたドメイン上の意図しない URL にも中継リクエストできてしまいます。そのため、パスの検証も行ったほうが安全です。

メソッドに応じてパラメータを設定

$input['method'] でメソッドを判定して、メソッドが GET であれば送信先 URL にパラメータを含め、POST であれば cURL オプション設定で CURLOPT_POSTFIELDS にパラメータを指定します。

JavaScript

JavaScript は以下のようになります。

fetch() で POST メソッドを使用して中継 PHP(fetch-posts.php)経由で API からデータを取得します。API エンドポイントの URL やパラメータはリクエストボディ(body)に指定します。

// WordPress REST API カスタムエンドポイントの URL(取得対象のエンドポイント)
const url = "https://example.com/wp-json/my-theme-api/v1/posts";

// API に渡すパラメータ(取得する投稿数とフィールド)
const params = {
  _fields: "title,link", // タイトルとリンクのみ取得(レスポンス軽量化)
  per_page: 5, // 取得件数:5件
};

// 中継 PHP(api/fetch-posts.php)へ POST メソッドでリクエストを送信
fetch("api/fetch-posts.php", {
  method: "POST", // フロントエンド → 中継PHP は POST に限定
  headers: {
    "Content-Type": "application/json", // 送信データは JSON 形式
  },
  body: JSON.stringify({
    url: url, // 中継先(取得対象)の WordPress API URL
    method: "GET", // 取得対象 API への HTTP メソッド(GET / POSTなど)
    params: params, // 取得対象 API に渡すパラメータ
  }),
})
  .then(async (response) => {
    // レスポンスヘッダーから Content-Type を取得(大文字小文字に依存しないように小文字に変換)
    const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
    let errorData = null;
    // レスポンスが失敗している(ステータスコードが 200 番台以外)
    if (!response.ok) {
      if (contentType.includes("application/json")) {
        errorData = await response.json();
      } else {
        // テキスト形式(HTML など)のエラーの場合はステータスコードのみを返す
        errorData = { error: `HTTP エラー: ${response.status}` };
      }
      // エラー内容を例外として投げる(catch で受ける)
      throw new Error(errorData.error);
    }
    // 成功時は JSON データをパースして返す
    return response.json();
  })
  .then((posts) => {
    // 投稿データをコンソールに出力(ここでUIに表示するなどが可能)
    console.log(posts);
  })
  .catch((error) => {
    // 通信エラーや API 側のエラーをコンソールに出力
    console.warn("投稿の取得エラー:", error);
  });

主な特徴。

中継ファイル(サーバー)経由の API 通信

ブラウザから直接 API にアクセスせず、fetch-posts.php という中継用 PHP ファイルに対して POST リクエストを送信します。これにより以下のような利点があります。

  • POST メソッドを使用することで、クエリ文字列の長さ制限(URL 長制限)を回避できる
  • API キーなどの秘匿情報をクライアントから隠すことができる(先の例同様)
  • 送信先や送信元のバリデーションを PHP 側で制御できる(先の例同様)

JSON 形式でのパラメータ送信

url, method, params を JSON としてリクエストボディ(body)に指定して、中継 PHP に渡し、実際の API リクエストを PHP 側で構築・実行します。

レスポンスのエラーハンドリング

レスポンスのステータスコードが 200 番台でない場合には、エラーを検出し、内容を例外として処理します。前述の JavaScript 同様、レスポンスの Content-Type を確認して、PHP 側が送ってきたエラーメッセージ(JSON やテキスト)を取り出しています。

受信した投稿データの処理

API のレスポンス(投稿データ)を console.log() で出力していますが、実際の運用ではここで HTML に表示するなどの処理に置き換えることが可能です。

まとめ:安全なAPIキー管理チェックリスト

  • ✅ APIキーは絶対に JavaScript に書かない
  • ✅ フロントエンドからAPIキーを含むリクエストは送らない
  • ✅ 中継用のPHPファイルを経由して通信する
  • ✅ APIキーはサーバー側のみに保持し、.htaccessで直接アクセスを制限する
  • ✅ APIキーを記述したファイルは .gitignore でバージョン管理外に置く

OpenWeather API を安全に使うサンプル

以下は OpenWeather API を安全に利用するために PHP 中継ファイルを使う例です。

my-project/
  ├── api/
  │   ├── config/
  │   │  ├── .htaccess       ← ←  api-config.php を直接見られないように制限
  │   │  └── api-config.php  ← ←  APIキーを定義する設定ファイル
  │   └── weather-proxy.php  ← ←  ブラウザ(JS)がリクエストする中継ファイル
  ├── index.html   ← ←  get-weather.js を読み込むフロントエンドテスト用 HTML ファイル
  └── js/
    └── get-weather.js    ← ←  fetch('api/weather-proxy.php') を実行

設定ファイル

設定ファイル api-config.php に API キーを定義します(実際の API キーの値を指定)。

<?php
define('OPENWEATHER_API_KEY', '123456789abcdefghijklmn');

以下は上記 api-config.php への直接アクセスをブロックするための .htaccess です。

Order deny,allow
Deny from all

Apache 2.4 以降であれば以下のようになります。

<RequireAll>
  Require all denied
</RequireAll>

Apache 2.2 と 2.4 の両方に対応したい場合は、以下のように記述できます。

<IfModule mod_authz_core.c>
  Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
  Order allow,deny
  Deny from all
</IfModule>

PHP 中継ファイル

以下は PHP 中継ファイルです。 許可する送信元は適宜変更します(29-30行目)。

送信されてくる POST リクエストボディには OpenWeather API の送信先(ベース URL)とクエリパラメータ(params)が格納されています。データは JSON 形式なので、$_POST が使えないため file_get_contents() に php://input を指定して取得します。

送信先の URL を検証し、また、場所のパラメータ(q)は必須なので指定されているかを検証します。

<?php
// API 設定ファイルの読み込みパス
$config_path = __DIR__ . '/config/api-config.php';

// 設定ファイルの存在チェック
if (!file_exists($config_path)) {
  http_response_code(500);
  header('Content-Type: application/json');
  echo json_encode(['error' => 'API 設定ファイルが見つかりません']);
  exit;
}

// 設定ファイルの読み込み
require_once $config_path;

// API_KEY 定義チェック
if (!defined('API_KEY')) {
  http_response_code(500);
  header('Content-Type: application/json');
  echo json_encode(['error' => 'API キーが定義されていません']);
  exit;
}

// JSON を返すヘッダーを設定
header('Content-Type: application/json');

// 許可する送信元(環境に合わせて変更します)
$allowedOrigins = [
  'https://example.com',
  'http://example.localhost:5501',
];

// リクエスト元のオリジンを取得
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';

// 許可された送信元かを確認し、CORS用ヘッダーを動的に設定
if (in_array($origin, $allowedOrigins, true)) {
  // 安全なオリジンにだけヘッダーを返す
  header('Access-Control-Allow-Origin: ' . $origin);
} else {
  // 不正なオリジンからのアクセスは拒否
  http_response_code(403);
  echo json_encode(['error' => '許可されていない送信元です']);
  exit;
}

// メソッドが POST であることを確認
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  http_response_code(405); // Method Not Allowed
  echo json_encode(['error' => 'POSTメソッドを使用してください']);
  exit;
}

// JSON 形式の POST リクエストボディを取得し、連想配列としてデコード
$input = json_decode(file_get_contents('php://input'), true);

// JSONエラーが発生、または配列でない場合
if (json_last_error() !== JSON_ERROR_NONE || !is_array($input)) {
  http_response_code(400);
  echo json_encode(['error' => '無効なJSONです: ' . json_last_error_msg()]);
  exit;
}

// リクエストパラメータを取得
$url = $input['url'] ?? '';
$params = $input['params'] ?? null;

// URLが未指定の場合はエラー
if (empty($url)) {
  http_response_code(400);
  echo json_encode(['error' => 'URLが指定されていません']);
  exit;
}

// URLのホワイトリストチェック
$parsedUrl = parse_url($url);

// スキームを検証(https のみを許可)
if (!in_array($parsedUrl['scheme'] ?? '', ['https'], true)) {
  http_response_code(403);
  echo json_encode(['error' => '無効なURLスキームです']);
  exit;
}

// 許可する送信先ホスト
$allowedHosts = ['api.openweathermap.org'];

// ホストの検証
if (!in_array($parsedUrl['host'] ?? '', $allowedHosts, true)) {
  http_response_code(403);  // Forbidden
  echo json_encode(['error' => '許可されていないURLです']);
  exit;
}

// 必須のパラメータ(q)
$city = $params['q'] ?? null;
if (empty($city)) {
  http_response_code(400);
  echo json_encode(['error' => '都市名(q パラメータ)は必須です']);
  exit;
}

// API キーをパラメータに追加
$params['APPID'] = OPENWEATHER_API_KEY;
// パラメータをクエリ文字列(URL エンコード形式)に変換
$queryString = http_build_query($params);
// URL を作成
$url .= (strpos($url, '?') === false ? '?' : '&') . $queryString;

// cURL 初期化とオプション
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); // HTTPメソッドを指定

// リクエストを実行し、レスポンスとHTTPステータスを取得
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// エラーハンドリングとレスポンス出力
if ($response === false) {
  // 通信自体が失敗した場合(ネットワークエラー等)
  http_response_code(500);
  echo json_encode(["error" => "cURL エラー: $curlError"]);
} else {
  // ステータスコード($httpCode)をチェック
  if ($httpCode < 200 || $httpCode >= 400) {
    // HTTP レベルでエラーとみなすステータスコードの場合
    http_response_code($httpCode);
    echo json_encode(["error" => "HTTP エラーまたは予期しないステータス: $httpCode"]);
  } else {
    // 成功
    http_response_code($httpCode);
    // API からの生レスポンスをそのまま返す(必要に応じて整形可能)
    echo $response;
  }
}

フロントエンド JavaScript

以下はフロントエンドの JavaScript です。

リクエストパラメータで mode: 'html' を指定すると、API からの戻り値はテキスト(HTML)になるので、レスポンスの取得では分岐しています。

  // OpenWeather ベース URL
const openWeatherUrl = "https://api.openweathermap.org/data/2.5/weather";

const openWeatherParams = {
  q: "New York",  // 場所
  units: "metric",
  lang: "ja",
  //mode: 'html'
};

fetch("api/weather-proxy.php", {
  method: "POST", // フロントエンド → 中継 PHP は POST に限定
  headers: {
    "Content-Type": "application/json", // 送信データは JSON 形式
  },
  body: JSON.stringify({
    url: openWeatherUrl, // OpenWeather API URL
    params: openWeatherParams, // OpenWeather API に渡すパラメータ
  }),
})
  .then(async (response) => {
    const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
    if (!response.ok) {
      let errorData = null;
      if (contentType.includes("application/json")) {
        errorData = await response.json();
      } else {
        // テキスト形式(HTML など)のエラーの場合はステータスコードのみを返す
        errorData = { error: `HTTP エラー: ${response.status}` };
      }
      throw new Error(errorData.error);
    }
    if (openWeatherParams.mode === "html") {
      // mode: 'html' を指定している場合(戻り値は HTML)
      return response.text();
    }
    return response.json();
  })
  .then((data) => {
    if (openWeatherParams.mode === "html") {
      console.log("天気情報の HTML: \n",data);
    } else {
      console.log("現在の天気:", data.weather[0].description);
      console.log("気温:", data.main.temp + "℃");
    }
  })
  .catch((error) => {
    console.error("天気の取得エラー:", error.message);
  });

関連ページ:OpenWeather の API からデータを HTML として取得