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と通信できます。
- API キーはサーバーサイド(PHP)だけで管理し、クライアントには一切渡さない
- PHP はサーバー上で実行され、ソースコードは外部から閲覧できない
- そのため、PHP に記述した API キーは漏洩のリスクが大幅に下がる
実装時のポイント
- クライアントから渡された URL やパラメータは必ずバリデーション・サニタイズする
- API キーは、環境変数や安全な設定ファイルから読み込む
- cURL や file_get_contents などで外部 API にアクセスし、結果を返す
- エラーが発生した場合は、適切な HTTP ステータスとエラーメッセージを返す
PHP によるプロキシ実装
以下がサンプルのファイル構成です。
- 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 中継ファイルを使う例です。
設定ファイル
設定ファイル 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);
});