はじめに:柔軟なGraphQLが「取りすぎ問題」を生む
GraphQL は「必要なデータだけを取れる」のが売りですが、実際のプロダクト運用では、次のようなことが起きがちです。
- とりあえず
user { id name email phone address ... }みたいなクエリを生やす - クライアントがどんどんフィールドを足していき、いつの間にか PII(個人情報)だらけ
- モバイル回線での通信が重くなり、P95 レイテンシも悪化
- 任意クエリが投げられるため、攻撃面も広がる
この記事では、これらの課題を「Persisted Queries(PQ)」+「Allowlist」の組み合わせで解決する方法を、設計・実装・運用まで一気通貫で解説します。
読み終わる頃には、あなたがモバイル向け BFF(Backend For Frontend)に PQ+Allowlist を導入し、
- PII(個人情報)の最小化
- 通信量と P95 レイテンシの削減
- 運用(ローテーション/監査/障害対応)まで自走できる
ところまで持っていくことをゴールにします。
1. 背景と課題整理:なぜ PQ+Allowlist なのか
1-1. 過剰取得の常態化:柔軟さが裏目に出る
GraphQL は「欲しいフィールドだけ選べる」反面、クライアント側で簡単にフィールドを足せてしまうため、気づくと過剰取得が常態化します。
- クエリが JSON 的に膨らむ:デザイナー/PM の要望でどんどんフィールド追加
- API バージョン管理が緩くなりやすい:REST のような
/v1/userの区切りが曖昧 - 結果として PII が常にレスポンスに含まれる:メールアドレス、電話番号、住所など
「とりあえず全部返す」が小規模のうちは楽ですが、ユーザ数・開発者数が増えると、セキュリティ面でもパフォーマンス面でもボトルネックになります。
1-2. モバイル制約:ちょっとの取りすぎが UX を壊す
モバイルアプリの BFF では、以下のような制約があります。
- 高い RTT(往復遅延時間):1 回のリクエストが重いほど体感が悪化
- 帯域制限:4G/5G でもエリアや時間帯で速度が落ちる
- 端末性能:低スペック端末では JSON パース自体が負荷になる
「ちょっとの取り過ぎ」でも、以下のような影響が積み上がります。
- レスポンスサイズ増 → 下りバイト増 → P95 レイテンシ悪化
- JSON パース時間増 → 初期描画がワンテンポ遅く感じる
特に「一覧+詳細」の画面で同じ PII を何度も返していると、ムダなバイトが積み上がり、UX をじわじわ悪くします。
1-3. 攻撃面の増加:任意クエリ送信の怖さ
GraphQL エンドポイントが「任意クエリを受け付ける」状態だと、以下のリスクが高まります。
- 高コストクエリ(ネスト深いクエリや N+1 発生クエリ)を意図的に投げられる
- 内部用のフィールド(運用者向け)を推測・探索される
- 未想定のパスで PII が引き出される
もちろん GraphQL サーバ側にクエリ制限やコスト制限を入れることはできますが、「そもそも任意クエリを受け付けない」ほうが安全です。
1-4. PQ+Allowlist で目指す姿
この記事で目指す状態を、あらためて整理します。
- PII 最小化:レスポンスから PII を削り、「安全な型(例:UserLite)」をデフォルトにする
- 下りバイト削減:対象画面で -30〜60% 程度のレスポンスサイズ削減
- P95 レイテンシ改善:対象エンドポイントで -15〜40% 程度の短縮
- キャッシュヒット率向上:クエリ ID 固定で CDN/BFF キャッシュを効かせやすくする
- 未登録クエリ受理率 0%:Allowlist 外のクエリはすべて 403 で遮断
このための武器が、GraphQL Persisted Queries(PQ)と Allowlist の組み合わせです。
2. PQ と Allowlist の仕組みを最短理解
2-1. PQ の登録フロー
Persisted Queries は、「クエリ本文をサーバ側に事前登録し、クライアントは ID だけ送る」という仕組みです。典型的なフローは次のとおりです。
- クエリ本文を用意する
- クエリを正規化する(スペースや改行の揺れをなくす)
- 正規化した文字列をハッシュ(例:SHA256)
- 論理名+バージョンから PQ ID を発行(例:
pq:v1:balance.summary) - 「PQ ID → クエリ本文」の対応を Allowlist に登録する
{
"id": "pq:v1:balance.summary",
"hash": "4e5f...a9",
"query": "query BalanceSummary { me { id, name, balanceLite { amount, currency } } }"
}
この JSON は、CI などから BFF に登録するイメージです。このコードでは PQ ID とハッシュ値とクエリ本文をセットで管理することで、「ID が改ざんされていないか」「クエリ本文が変わっていないか」を後で確認しやすくしています。
2-2. クライアント実行:本文送信は禁止
クライアントから BFF にクエリを投げるときは、クエリ本文を送らず、次のような JSON だけ送ります。
POST /gql
{
"id": "pq:v1:balance.summary",
"variables": {
"currency": "JPY"
},
"signature": "..." // 後述する HMAC 署名
}
このとき、BFF 側は次のように振る舞います。
idが Allowlist に存在しなければ 403 Forbiddenidは存在するが、変数がバリデーションから外れていれば 400 Bad Request- 有効な
idとvariablesであれば、Allowlist 上のクエリ本文を使って GraphQL サーバに問い合わせ
こうすることで、「任意のクエリ本文を投げる」ということ自体を禁止します。
2-3. 署名と改ざん対策:HMAC で守る
PQ ID と変数が正しいクライアントからのものかを確認するために、HMAC 署名を使います。BFF の秘密鍵(HMAC_SECRET)を使い、次のような文字列に署名します。
signature = HMAC_SHA256(
secret = HMAC_SECRET,
data = `${id}:${JSON.stringify(sortedVariables)}`
)
このコードでは、変数をソートした JSON 文字列と PQ ID を結合したものに HMAC をかけることで、「ID または変数が途中で改ざんされていないか」を検出できるようにしています。
BFF は signature を検証し、失敗したら 401 Unauthorized などで即時に拒否します。
2-4. バージョニング:論理名+版を付与
PQ は、論理名+バージョンで ID を付けると運用しやすくなります。
pq:v1:balance.summarypq:v2:balance.summary(フィールド追加版)
v1 と v2 をしばらく並行稼働させ、アクセスログを見ながら徐々に v1 の利用を減らし、最終的に失効させる、という流れを作れます。
3. 設計の勘所:スキーマ/権限/キャッシュ
3-1. フィールド最小化:安全な型(UserLite)を用意する
まずは「スキーマ設計」から PII 最小化を行います。典型的には、フルなユーザ情報とは別に「安全な型」を用意します。
# PII を含むフルなユーザ
type User {
id: ID!
name: String!
email: String! # PII
phoneNumber: String # PII
address: String # PII
createdAt: String!
}
# 通常画面で使う Lite 版(PII を含まない)
type UserLite {
id: ID!
displayName: String!
avatarUrl: String
}
このコードでは、アプリ内の多くの画面で UserLite を使うようにすることで、「うっかり PII を返す」ケースを減らす意図があります。フルな User は、本当に必要な画面(本人確認など)に限定します。
3-2. ロール別 Allowlist:ANON/USER/ADMIN
Allowlist は、ユーザロールごとに分離して管理します。
role = ANON(未ログイン)role = USER(一般ユーザ)role = ADMIN(管理者)
BFF はアクセストークンからロールを取り出し、次のような構造で Allowlist を見るイメージです。
{
"ANON": ["pq:v1:public.news.list"],
"USER": ["pq:v1:balance.summary"],
"ADMIN": ["pq:v1:user.detail"]
}
このコードでは、ロールごとに許可されている PQ ID をリスト化することで、「一般ユーザが管理者用クエリを叩けないようにする」という意図があります。
3-3. キャッシュ鍵設計:pqID + normalizedVars + role
PQ+Allowlist を使うと、キャッシュ設計もしやすくなります。CDN や BFF 内キャッシュでは、次のようなキーにすると安全です。
cacheKey = `${pqId}:${normalizedVars}:${role}`
このコードでは、PQ ID のみではなく、正規化済み変数とロールを含めることで、「別ユーザのキャッシュを誤って共有する」ことを防ぐ意図があります。特に PII を含むレスポンスでは、ロールやユーザ ID を鍵に含めることが重要です。
3-4. 変数バリデーション:サーバ側で必ずチェック
変数の範囲をサーバ側で必ずチェックします。例として、金額の上限を決めておくイメージです。
function validateTransferVariables(vars) {
if (typeof vars.amount !== "number") {
throw new Error("amount must be number");
}
if (vars.amount <= 0 || vars.amount > 1_000_000) {
throw new Error("amount out of range");
}
}
このコードでは、金額が数値かどうかと、上限・下限をチェックすることで、「極端に大きな値でサーバを負荷させる」ようなリクエストを防ぐ意図があります。
3-5. エラーポリシー:未登録 ID は即 403
エラーポリシーはシンプルに決めておきます。
- 未登録 PQ ID → 403 Forbidden(Allowlist で拒否)
- 変数バリデーション NG → 400 Bad Request
- スロットル超過 → 429 Too Many Requests +
Retry-Afterヘッダ
こうしておくと、クライアント側の実装や、監視ダッシュボードでの原因分析がシンプルになります。
4. 実装ハンズオン(最小構成:BFF+クライアント)
4-1. サーバ側(BFF)の最小実装
ここでは、Node.js(Express)での最小例を示します。実運用では DB や Redis で管理しますが、サンプルではインメモリで扱います。
const express = require("express");
const crypto = require("crypto");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());
const HMAC_SECRET = process.env.HMAC_SECRET || "dev-secret";
// 簡易 Allowlist(本番では DB / Redis 等に保存)
const allowlist = new Map(); // pqId -> { query, roles }
// CI 専用エンドポイント:PQ 登録
app.post("/pq/register", (req, res) => {
const { id, query, roles } = req.body;
if (!id || !query) {
return res.status(400).json({ error: "id and query are required" });
}
// 正規化(ここでは単純に trim のみ。実運用では専用ライブラリ推奨)
const normalized = query.trim();
const hash = crypto
.createHash("sha256")
.update(normalized, "utf8")
.digest("hex");
allowlist.set(id, { query: normalized, hash, roles: roles || ["USER"] });
return res.json({ id, hash });
});
// HMAC 署名検証
function verifySignature(id, variables, signature) {
const sortedVars = JSON.stringify(sortObject(variables || {}));
const data = `${id}:${sortedVars}`;
const expected = crypto
.createHmac("sha256", HMAC_SECRET)
.update(data, "utf8")
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// オブジェクトのキーをソート
function sortObject(obj) {
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {});
}
// 擬似的にロールを決める(本番では JWT などから取得)
function getRoleFromRequest(req) {
// 例: ヘッダ X-ROLE: ANON/USER/ADMIN
return req.headers["x-role"] || "ANON";
}
// GraphQL 実行エンドポイント
app.post("/gql", async (req, res) => {
const { id, variables, signature } = req.body;
const role = getRoleFromRequest(req);
if (!id) {
return res.status(400).json({ error: "id is required" });
}
const entry = allowlist.get(id);
if (!entry) {
return res.status(403).json({ error: "pq id not allowed" });
}
if (!entry.roles.includes(role)) {
return res.status(403).json({ error: "role not allowed" });
}
if (!signature || !verifySignature(id, variables, signature)) {
return res.status(401).json({ error: "invalid signature" });
}
try {
validateVariables(id, variables);
// ここで GraphQL サーバに実際のクエリを投げる(擬似コード)
const result = await executeGraphQL(entry.query, variables, { role });
// ログ用に trace_id / pq_id を埋め込む
console.log(
JSON.stringify({
level: "info",
pq_id: id,
role,
trace_id: req.headers["x-trace-id"] || crypto.randomUUID(),
})
);
return res.json(result);
} catch (e) {
return res.status(400).json({ error: e.message });
}
});
// PQ ごとの変数バリデーション
function validateVariables(id, variables) {
if (id === "pq:v1:transfer.create") {
const amount = variables?.amount;
if (typeof amount !== "number" || amount <= 0 || amount > 1_000_000) {
throw new Error("amount out of range");
}
}
// 他の PQ も同様にケースごとに検証
}
// GraphQL 実行のダミー関数
async function executeGraphQL(query, variables, context) {
// 実運用では Apollo Server などを叩く
return { data: { ok: true } };
}
app.listen(3000, () => {
console.log("BFF started on :3000");
});
このコードでは、/pq/register で PQ を登録し、/gql で PQ ID と変数、署名を受け取って GraphQL を実行する構成にしています。Allowlist に存在しない ID や、ロールが許可されていない ID は即 403 にすることで、「任意クエリを受け付けない BFF」を作る意図があります。また、verifySignature で HMAC 署名を検証することで、ID や変数の改ざんを検出できるようにしています。
4-2. クライアント側(React Native / Flutter イメージ)
クライアント側では、ビルド時に PQ ID を埋め込み、クエリ本文は同梱しないようにします。ここでは TypeScript を例にします。
// ビルド時に生成された PQ マップ(抜粋)
export const PQ = {
balanceSummary: {
id: "pq:v1:balance.summary",
},
transferCreate: {
id: "pq:v1:transfer.create",
},
};
このコードでは、クエリ本文を含めずに PQ ID だけをバンドルすることで、「アプリ配布物からクエリ本文が漏れないようにする」意図があります。
次に、変数をローカルで事前検証しつつ、署名付きで BFF に投げる関数です。
import { PQ } from "./pq";
import { createHmac } from "crypto";
const HMAC_SECRET = "client-side-secret"; // 実運用では別の仕組みで共有 or 署名はサーバ側で
function sign(id: string, variables: any) {
const sorted = JSON.stringify(sortObject(variables || {}));
const data = `${id}:${sorted}`;
return createHmac("sha256", HMAC_SECRET).update(data, "utf8").digest("hex");
}
function sortObject(obj: any) {
return Object.keys(obj)
.sort()
.reduce((acc: any, key) => {
acc[key] = obj[key];
return acc;
}, {});
}
// 変数スキーマの簡易チェック(本番では Zod / JSON Schema 推奨)
function validateTransferInput(input: { amount: number }) {
if (typeof input.amount !== "number") throw new Error("amount must be number");
if (input.amount <= 0 || input.amount > 1_000_000) {
throw new Error("amount out of range");
}
}
export async function callTransferCreate(input: { amount: number }) {
validateTransferInput(input);
const id = PQ.transferCreate.id;
const signature = sign(id, input);
const res = await fetch("https://bff.example.com/gql", {
method: "POST",
headers: { "Content-Type": "application/json", "X-ROLE": "USER" },
body: JSON.stringify({ id, variables: input, signature }),
});
if (res.status === 403) {
// すぐにアプリ更新を促す
alert("アプリのバージョンが古い可能性があります。最新バージョンに更新してください。");
return;
}
if (res.status === 429) {
// 簡易的な指数バックオフ
await new Promise((resolve) => setTimeout(resolve, 1000));
return callTransferCreate(input);
}
const json = await res.json();
return json;
}
このコードでは、ローカルで変数チェックを行ったうえで署名付きリクエストを送ることで、「明らかにおかしい入力はクライアント側で早期に弾きつつ、不正な改ざんをサーバ側で確実に検出する」という二段構えにしています。また、403 の場合はアプリ更新を促し、429 の場合は簡単なバックオフで再試行することで、UX を壊しにくいエラーハンドリングをしています。
4-3. テスト観点チェックリスト
実装したら、最低限次のテストは自動化しておくと安心です。
- 未登録 ID 送信 → 403
- ロール違い(ANON で USER 用 ID を叩く)→ 403
- 変数外れ値(amount <= 0 / > 上限)→ 400
- 署名改ざん → 401
- 競合更新 → ETag / If-None-Match で再取得
ETag を使う場合は、「レスポンスの内容に応じて ETag を付け、クライアントが If-None-Match で再リクエストしたとき、内容に変更がなければ 304 を返す」ようにすることで、通信量の削減に繋がります。
5. 運用・ローテーション・監査
5-1. ローテーション設計:v1 → v2 を並行稼働
PQ のバージョンアップは、並行稼働 → 使用状況の確認 → 旧版の失効という形で進めます。
pq:v1:balance.summaryを運用中- 新仕様で
pq:v2:balance.summaryを追加 - アプリの新バージョンで v2 を使うように変更
- ダッシュボードで v1 / v2 のヒット率を確認
- v1 のヒットが一定以下(例:全体の 1% 未満)になったら失効
この流れを Runbook(手順書)としてドキュメントにしておくと、チーム内で属人化せずに回せます。
5-2. クエリ運用 Runbook(サンプル)
- 登録:CI から
/pq/registerにクエリを登録(PR マージ時など) - 配布:クライアントの PQ マップ(ID のみ)をビルドして配布
- リリース後確認:ダッシュボードで PQ ごとの P95 / 下りバイト / エラー率を確認
- ローテーション:新バージョンの PQ を追加し、旧版のヒットをモニタリング
- 失効:一定期間アクセスがなければ Allowlist から削除
5-3. 監視 KPI テンプレート
ダッシュボードには、少なくとも次の KPI を PQ ID ごとに出すとよいです。
- P95 レイテンシ (ms)
- Downlink (kB):レスポンスサイズの平均/P95
- CacheHit (%):CDN/BFF キャッシュのヒット率
- 403 率 (%):未登録/ロール不一致のリクエスト比率
- 429 率 (%):スロットルに引っかかった比率
これを PQ ID 別に見ることで、「どの画面が重いか」「どのクエリで未登録アクセスが多いか」をすぐに把握できるようにしています。
5-4. インシデント対応:誤配信 ID の即ブロック
もし「誤った PQ ID を配布してしまった」「ロール設定を間違えた」というインシデントが起きた場合の流れです。
- 影響 PQ ID を特定(ログの
pq_idを検索) - Allowlist から該当 ID を無効化
- 403 の急増を確認(意図通りブロックできているか)
- ログから影響端末(caller 情報)を抽出
- 必要なら該当ユーザに告知/アプリ更新を案内
Allowlist から PQ ID を消すだけで即時にブロックできるようにしておくと、切り戻しが非常にやりやすくなります。
5-5. 監査ログ:PII はハッシュ化して保存
監査ログには、次のような情報を残しておくとよいです。
pq_idrolecaller(アプリバージョンや端末種別)variables_hash(変数のハッシュ値)
{
"timestamp": "2024-01-01T00:00:00Z",
"pq_id": "pq:v1:balance.summary",
"role": "USER",
"caller": "ios/1.2.3",
"variables_hash": "b3a9..."
}
このコードでは、変数そのものではなくハッシュ値だけを保存することで、「あとから特定のリクエストを再現できる程度の情報は残しつつ、PII をログに残さない」というバランスを取る意図があります。
5-6. セキュリティ定期点検:Allowlist 棚卸し
- 一定期間(例:90日)アクセスのない PQ ID を自動失効
- ADMIN ロール専用の PQ を再確認(不要なものを削除)
- PII を含むレスポンスの有無をサンプリングチェック
これにより、「昔の機能で使っていたが、今は誰も叩いていない PQ」が放置されるのを防ぎます。
6. チェックリスト:導入完了の目安
本記事の内容を一通り導入できたかどうか、次のチェックリストで確認できます。
- クエリ本文は クライアント配布物に一切含まれていない
- Allowlist にない PQ ID はすべて 403 で即拒否される
- PII を含むフィールドは「Lite 型」などに置き換えられている
- 変数はサーバ側でホワイト/ブラックリストや範囲チェックが行われている
- キャッシュ鍵には pq_id + normalizedVars + role が含まれている
- P95 レイテンシ/下りバイト/403 率/429 率を PQ ID ごとに監視している
ここまでできていれば、モバイル BFF において PQ+Allowlist を活かしながら、PII 最小化と高速化、運用容易性を両立できているはずです。
7. ありがちな落とし穴と回避策
7-1. クエリ本文がどこかに残っている
落とし穴: クライアント配布物(IPA / APK / JS バンドル)を調べると、クエリ本文がそのまま残っている。
回避策:
- ビルド後にバンドルをスキャンし、
queryやmutationキーワードを検出したら CI を失敗させる - 完全に PQ ID のみを埋め込む構成にする(本文はサーバ側のみ)
7-2. 変数が広すぎる
落とし穴: variables に何でも入れられてしまい、Range や正規表現での制限がない。
回避策:
- 各 PQ ごとに変数スキーマを定義(Zod / JSON Schema など)
- サーバ側でホワイトリスト/ブラックリストと範囲チェック
- 文字列は正規化(全角・半角、空白トリムなど)したうえで検証
7-3. ロール混在
落とし穴: Allowlist がロールに依存しておらず、「たまたま UI からは叩かれていない」状態で ADMIN 用 PQ が USER からも使える。
回避策:
- アクセストークンから role を取り出し、必ずキャッシュキー/ログ/Allowlist 判定に含める
- Allowlist をロール別に完全分離する
7-4. キャッシュ暴発
落とし穴: キャッシュキーに PQ ID しか入れておらず、異なる変数・異なるロールのレスポンスが混ざってしまう。
回避策:
- キャッシュキーに pq_id + normalizedVars + role を必ず含める
- ユーザ固有情報を含むレスポンスでは、ユーザ ID も含める
8. まとめ
GraphQL Persisted Queries(PQ)と Allowlist を組み合わせることで、
- クエリを ID 固定・許可制にし、任意クエリを封じる
- PII を含むフィールドを減らし、レスポンスから個人情報を最小化する
- クエリ単位でキャッシュ・監視・ローテーションを行いやすくする
ことができます。
実装面では、/pq/register と /gql の 2 エンドポイントを用意し、HMAC 署名・ロール別 Allowlist・変数バリデーションを組み合わせるだけで、かなり強固な BFF を構築できます。運用面では、PQ ID ごとに P95 レイテンシ・下りバイト・キャッシュヒット率・403/429 率をモニタリングしつつ、v1 → v2 のローテーションと未使用 PQ の棚卸しを回していくことが重要です。
まずは 1 画面分のクエリから PQ+Allowlist を試し、「下りバイト -30〜60%」「P95 レイテンシ -15〜40%」「未登録クエリ受理率 0%」を狙って改善していきましょう。
9. 参考URL
- GraphQL公式ドキュメント(GraphQLの基本仕様の確認に)
- Apollo等のPersisted Queriesドキュメント(具体的な実装例の参考に)
- 各種APM/メトリクスツールのドキュメント(
pq_id別ダッシュボード構成の参考に)
実際のプロダクトでは使っているGraphQLサーバやインフラに合わせて、公式ドキュメントと照らしながら仕組みを組み込むのが安全。