結論

BFFは、画面の種類ごとに置く専用のバックエンドです。複数のAPIを集めて、その画面に必要な形だけを返します。

この記事でわかること

  • API Gateway・GraphQLとの違い表(5観点)
  • 2回・7,428バイトが1回・190バイトになる実測
  • 使う・使わない9場面の判断表と認証・障害時の形

2026年10月17日更新|Node.js 22.22.2・curl 8.5.0 で実行確認

Web の画面とスマホアプリが、それぞれ自分専用の BFF を通して、同じバックエンドのサービスを使う形です。

flowchart TD
    W[Web画面] --> BW[Web用BFF]
    M[スマホアプリ] --> BM[スマホ用BFF]
    BW --> U[ユーザーサービス]
    BW --> O[注文サービス]
    BM --> U
    BM --> O

よく聞かれることへの答えを先にまとめます。

知りたいこと 答え 詳しくは
BFFとは 画面ごとに置く、専用の中継サーバー BFFとは
API Gatewayとの違い 全体の共通の入口か、画面専用の組み立て役か 違いの表
GraphQLとの違い APIの問い合わせ方式か、置くサーバーか 違いの表
何が減るか 画面1つあたりの通信回数と、受け取るデータ量 Node.jsで作って比べる
認証はどうするか トークンはBFFが持ち、ブラウザはCookieだけ BFFでの認証
使わないほうがよい場面 画面が1種類だけ、どの画面も同じデータ 判断表

確認環境:Ubuntu 24.04・Node.js 22.22.2・curl 8.5.0。Node.js の標準モジュールだけで、ユーザーサービス・注文サービスと、Web 用・スマホ用の BFF を立てて動かし、リクエスト回数・応答サイズ・時間・認証・障害時の出力を載せています。BFF の定義と使う場面は Sam Newman 氏の記事と Microsoft Azure アーキテクチャセンター、認証は RFC 10017(OAuth 2.0 for Browser-Based Applications)、Cookie の属性は MDN で確かめました(確認日 2026年10月17日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「BFF(Backend for Frontend)とは|構成図とNode.js実装」

BFF(Backend for Frontend)とは

BFF(Backend for Frontend)は、Web・iOS・Android など画面(クライアント)の種類ごとに用意する、その画面専用のバックエンドです。 「Backends For Frontends」と複数形で書くこともあります。読み方は「ビーエフエフ」です。

この名前を広めたのは Sam Newman 氏の 2015年11月の記事です。記事では「ユーザー体験1つにつき、バックエンド1つ」(one backend per user experience)という形で説明されています。

BFF の基本は次の3点です。

  • 画面とバックエンドのあいだに置く:画面からはBFFだけを呼び、BFF が奥のサービス(ユーザー・注文・在庫など)を呼びます。
  • 画面の種類ごとに分ける:Web 用とスマホ用で、欲しい項目や件数が違うからです。Microsoft のドキュメントも、Web 用・モバイル用のように画面ごとに BFF を作る形を示しています。
  • その画面を作るチームが持つ:Newman 氏は、BFF は画面と同じチームが保守するのが一般的だとしています。画面の都合で API を変えるときに、別のチームを待たずに済みます。

「BFF 意味」で調べたときの別の意味

日常の会話や SNS では、BFF は「Best Friends Forever(ずっと親友)」の略として使われます。システム開発の文脈で BFF と言えば、この記事の Backend for Frontend を指します。

BFFが生まれた理由

1つの汎用 API を、Web とスマホなど複数の画面が使うと、次のことが起きます。

起きること 例 BFFでの解き方
要らない項目まで受け取る(オーバーフェッチ) スマホは名前だけ欲しいのに、住所・設定まで届く BFFが必要な項目だけ返す
1画面のために何回も呼ぶ(アンダーフェッチ) ユーザー情報と注文一覧を別々に取りに行く BFFが奥でまとめて呼ぶ
画面ごとの要望がバックエンドに集まる Web とスマホの要望がぶつかり、変更待ちが増える 画面ごとのBFFで、その画面の都合だけを扱う

Microsoft のドキュメントは、汎用のバックエンドが複数のフロントエンドから相反する要望を受け、更新が頻繁になり、開発の詰まりが起きやすいことを問題として挙げています。

BFFのアーキテクチャ(構成図と役割)

BFF を使う構成では、多くの場合、外側から 画面 → (API Gateway)→ BFF → 各サービス の順に並びます。

flowchart TD
    C[Web・スマホの画面] --> G[API Gateway<br>共通の入口]
    G --> B[画面ごとのBFF<br>集約・整形]
    B --> S1[ユーザー]
    B --> S2[注文]
    B --> S3[在庫]

Microsoft の例では、API Gateway(Azure API Management)が認可・監視・キャッシュ・ルーティングのような全体で共通の処理を受け持ち、その後ろに画面ごとの BFF を置いています。BFF の仕事は次の3つです。

1集める(集約)

1つの画面に必要なデータを、複数のサービスから集めます。互いに関係しない呼び出しは並列にします。Newman 氏も、依存しない呼び出しは並列にするよう勧めています。

2整える(整形)

項目を選び、件数を絞り、画面でそのまま使える形にします。下の実測では、スマホ用 BFF の応答は 190 バイトでした。

3画面の代わりに持つ(セッション)

アクセストークンを BFF が持ち、ブラウザには HttpOnly の Cookie だけを渡します。RFC 10017 が示す BFF の形です。

逆に、注文の確定や在庫の引き当てのような業務のルールは BFF に書きません。 Microsoft のドキュメントは、BFF は画面固有の処理だけを扱うべきだとしています。

関連記事「マイクロサービスとは|モノリスとの違い・採用判断・移行の進め方」で扱っています。

BFFとAPI Gateway・GraphQLの違い

3つは「どれか1つを選ぶもの」ではなく、役割が違います。

観点 API Gateway BFF GraphQL
何か システム全体の共通の入口 画面の種類ごとの専用サーバー APIの問い合わせ方式(スキーマと型)
主な仕事 ルーティング・認証の確認・流量制限・監視 複数APIの集約・画面向けの整形・セッション 画面が欲しい項目を問い合わせで指定する
置く数 全体で1つ(または少数) 画面の種類の数だけ 方式なので数の概念はない
持つチーム 基盤・バックエンドのチーム その画面を作るチーム API を提供するチーム
画面ごとの違い 吸収しない(全画面共通の処理) コードで吸収する 問い合わせの書き方で吸収する

ドキュメントに書かれている関係は次のとおりです。

  • API Gateway と BFF:Microsoft は、全体で共通の処理(認可・流量制限・ルーティングなど)は BFF ではなく、ゲートウェイ側のパターンで扱うとしています。BFF を API Gateway の後ろに置く併用の例を示しています。また、API Gateway とマイクロサービスの組み合わせだけで、BFF が勧められる場面の一部をまかなえることもあると書いています。
  • GraphQL と BFF:GraphQL は、クライアントが必要なデータだけを問い合わせられる API の方式です(graphql.org)。Microsoft は、画面ごとのリゾルバー(項目の取り出し処理)を持つ GraphQL を使っているなら、BFF を足しても価値が小さいことがあるとしています。一方で、BFF 自体を GraphQL サーバーとして作る構成もあります。

関連記事「GraphQLとRESTの違い|同じ画面を両方で作ってリクエスト数を実測」で扱っています。

Node.jsでBFFを作って比べる(Web用・スマホ用)

ここからは、実際に動く BFF を作り、BFF なしで画面がバックエンドを直接呼ぶ場合と比べます。使うのは Node.js の標準モジュールだけで、npm で入れるものはありません。

サーバー ポート 役割
ユーザーサービス(users.mjs) 25201 ログイン(トークン発行)とユーザー情報
注文サービス(orders.mjs) 25202 ユーザーの注文一覧(12件)
Web 用 BFF(bff.mjs web) 25210 名前・会員ランク・直近5件の注文と商品名
スマホ用 BFF(bff.mjs mobile) 25220 名前・直近3件の注文の番号・状態・金額

どちらのバックエンドも、処理に 100 ミリ秒かかるようにしています(DELAY で変えられます)。

バックエンド2つ(ユーザー・注文)

共通部品です。署名つきのトークンを発行・検証します(JWT と同じ考え方を短く書いたものです)。

// common.mjs  バックエンド共通:署名つきトークンの発行と検証、JSONで返す関数
import { createHmac } from "node:crypto";

const SECRET = "demo-secret-only-for-local";

export function issueToken(userId, ttlSec = 900) {
  const body = Buffer.from(JSON.stringify({ sub: userId, exp: Math.floor(Date.now() / 1000) + ttlSec })).toString("base64url");
  const sig = createHmac("sha256", SECRET).update(body).digest("base64url");
  return `${body}.${sig}`;
}

export function verify(req) {
  const m = /^Bearer (.+)\.(.+)$/.exec(req.headers.authorization ?? "");
  if (!m) return null;
  const sig = createHmac("sha256", SECRET).update(m[1]).digest("base64url");
  if (sig !== m[2]) return null;
  const p = JSON.parse(Buffer.from(m[1], "base64url").toString());
  return p.exp > Date.now() / 1000 ? p : null;
}

export function send(res, status, obj, headers = {}) {
  const body = JSON.stringify(obj);
  res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), ...headers });
  res.end(body);
}

export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

ユーザーサービスです。画面では使わない項目(設定・社内用の値など)も含めて返します。汎用の API では、こうした項目が混ざるのはよくあることです。

// users.mjs  ユーザーサービス(ポート 25201)。ログインとユーザー情報
import http from "node:http";
import { issueToken, verify, send, sleep } from "./common.mjs";

const DELAY = Number(process.env.DELAY ?? 100); // 処理にかかる時間(ミリ秒)

const user = {
  id: 1, loginId: "yamada", name: "山田 花子", nameKana: "ヤマダ ハナコ",
  email: "hanako@example.com", phone: "090-0000-0000", birthday: "1995-04-01",
  rank: "GOLD", points: 1280, createdAt: "2024-01-15T10:00:00Z",
  address: { zip: "100-0001", pref: "東京都", city: "千代田区", line1: "1-1-1", line2: "サンプルビル 5F" },
  preferences: { newsletter: true, language: "ja", theme: "light", notifyOrder: true, notifyCampaign: false },
  internal: { riskScore: 12, lastLoginIp: "203.0.113.10", segment: "A-2", crmId: "CRM-0001" },
};

http.createServer(async (req, res) => {
  console.log(`[users] ${req.method} ${req.url}`);
  await sleep(DELAY);
  if (req.method === "POST" && req.url === "/token") {
    let raw = "";
    for await (const c of req) raw += c;
    const { loginId, password } = JSON.parse(raw || "{}");
    if (loginId === "yamada" && password === "pass") {
      return send(res, 200, { access_token: issueToken(1), token_type: "Bearer", expires_in: 900, user_id: 1 });
    }
    return send(res, 401, { error: "invalid_grant" });
  }
  if (req.method === "GET" && req.url === "/users/me") {
    const p = verify(req);
    if (!p) return send(res, 401, { error: "invalid_token" });
    return send(res, 200, user);
  }
  send(res, 404, { error: "not_found" });
}).listen(25201, "127.0.0.1");

注文サービスです。1件に商品・配送・支払いの情報まで入った注文を12件返します。

// orders.mjs  注文サービス(ポート 25202)。ユーザーの注文一覧
import http from "node:http";
import { verify, send, sleep } from "./common.mjs";

const DELAY = Number(process.env.DELAY ?? 100);

const orders = Array.from({ length: 12 }, (_, i) => ({
  id: `ORD-${String(1012 - i)}`, userId: 1,
  status: ["SHIPPED", "DELIVERED", "PROCESSING"][i % 3],
  orderedAt: `2026-10-${String(16 - i).padStart(2, "0")}T09:30:00Z`,
  items: [
    { sku: `SKU-${100 + i}`, name: "ワイヤレスマウス", price: 3980, qty: 1, warehouse: "TKY-02" },
    { sku: `SKU-${200 + i}`, name: "USB-C ケーブル 1m", price: 1280, qty: 2, warehouse: "TKY-02" },
  ],
  subtotal: 6540, shippingFee: 0, tax: 654, total: 7194,
  shipping: { method: "STANDARD", carrier: "SampleExpress", trackingNo: `TRK${900000 + i}`, zip: "100-0001", address: "東京都千代田区1-1-1 サンプルビル 5F" },
  payment: { method: "CARD", brand: "VISA", last4: "4242", authCode: `AUTH${i}` },
}));

http.createServer(async (req, res) => {
  console.log(`[orders] ${req.method} ${req.url}`);
  await sleep(DELAY);
  const p = verify(req);
  if (!p) return send(res, 401, { error: "invalid_token" });
  const u = new URL(req.url, "http://x");
  if (req.method === "GET" && u.pathname === "/orders") {
    return send(res, 200, orders.filter((o) => o.userId === Number(u.searchParams.get("userId"))));
  }
  send(res, 404, { error: "not_found" });
}).listen(25202, "127.0.0.1");

Web用BFF・スマホ用BFF(bff.mjs)

1つのファイルを、引数 web か mobile で2つの BFF として起動します。画面ごとの違いは shapes の中だけで、それ以外(ログイン・並列呼び出し・タイムアウト・部分応答)は同じです。

// bff.mjs  画面ごとのBFF。 node bff.mjs web(ポート 25210) / node bff.mjs mobile(ポート 25220)
import http from "node:http";
import { randomBytes } from "node:crypto";
import { send } from "./common.mjs";

const CLIENT = process.argv[2];                 // "web" か "mobile"
const PORT = CLIENT === "web" ? 25210 : 25220;
const USERS = "http://127.0.0.1:25201";         // 呼び出し先は固定(許可した宛先だけ)
const ORDERS = "http://127.0.0.1:25202";
const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? 800); // バックエンド1本あたりの待ち時間
const sessions = new Map();                      // sid -> { token, userId }(トークンはサーバー側だけに置く)

// 画面ごとの「形」。ここがBFFの本体
const shapes = {
  web: (user, orders) => ({
    user: user && { name: user.name, email: user.email, rank: user.rank, points: user.points },
    recentOrders: orders && orders.slice(0, 5).map((o) => ({
      id: o.id, status: o.status, orderedAt: o.orderedAt, total: o.total,
      items: o.items.map((it) => `${it.name} ×${it.qty}`),
    })),
  }),
  mobile: (user, orders) => ({
    name: user?.name ?? null,
    orders: orders && orders.slice(0, 3).map((o) => ({ id: o.id, status: o.status, total: o.total })),
  }),
};

async function callBackend(url, token) {
  const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
  if (!r.ok) throw new Error(`${url} -> ${r.status}`);
  return r.json();
}

function sidOf(req) {
  const m = /(?:^|;\s*)__Host-Http-sid=([0-9a-f]+)/.exec(req.headers.cookie ?? "");
  return m && sessions.has(m[1]) ? m[1] : null;
}

http.createServer(async (req, res) => {
  // CSRF対策:独自ヘッダーが無いリクエストは受け付けない(別サイトからは付けられない)
  if (req.headers["x-requested-by"] !== CLIENT) return send(res, 403, { error: "missing X-Requested-By" });

  if (req.method === "POST" && req.url === "/login") {
    let raw = "";
    for await (const c of req) raw += c;
    const r = await fetch(`${USERS}/token`, { method: "POST", body: raw, headers: { "Content-Type": "application/json" } });
    if (!r.ok) return send(res, 401, { error: "login failed" });
    const t = await r.json();
    const sid = randomBytes(16).toString("hex");
    sessions.set(sid, { token: t.access_token, userId: t.user_id });
    return send(res, 200, { ok: true }, {
      "Set-Cookie": `__Host-Http-sid=${sid}; Path=/; Secure; HttpOnly; SameSite=Strict`,
    });
  }

  const sid = sidOf(req);
  if (!sid) return send(res, 401, { error: "not logged in" });
  const { token, userId } = sessions.get(sid);

  if (req.method === "GET" && req.url === "/api/home") {
    const t0 = performance.now();
    // 2つのバックエンドを並列に呼ぶ。片方が失敗しても、もう片方で画面を返す
    const [u, o] = await Promise.allSettled([
      callBackend(`${USERS}/users/me`, token),
      callBackend(`${ORDERS}/orders?userId=${userId}`, token),
    ]);
    const degraded = [];
    if (u.status === "rejected") { degraded.push("user"); console.log(`[bff-${CLIENT}] user: ${u.reason.message}`); }
    if (o.status === "rejected") { degraded.push("orders"); console.log(`[bff-${CLIENT}] orders: ${o.reason.message}`); }
    const body = shapes[CLIENT](u.value ?? null, o.value ?? null);
    if (degraded.length) body.degraded = degraded;
    console.log(`[bff-${CLIENT}] /api/home ${Math.round(performance.now() - t0)}ms`);
    return send(res, degraded.length === 2 ? 503 : 200, body);
  }

  if (req.method === "POST" && req.url === "/logout") {
    sessions.delete(sid);
    return send(res, 200, { ok: true }, { "Set-Cookie": "__Host-Http-sid=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0" });
  }
  send(res, 404, { error: "not_found" });
}).listen(PORT, "127.0.0.1");

4つのサーバーを起動します(ターミナルを4つ開くか、末尾に & を付けます)。

node users.mjs
node orders.mjs
node bff.mjs web
node bff.mjs mobile

同じデータが、画面ごとに違う形で返る

Web 用 BFF にログインしてから、ホーム画面のデータを取ります(-c・-b は Cookie の保存と送信です)。

curl -s -H 'X-Requested-By: web' -c jar.txt -X POST \
  -d '{"loginId":"yamada","password":"pass"}' http://127.0.0.1:25210/login
curl -s -b jar.txt -H 'X-Requested-By: web' http://127.0.0.1:25210/api/home

Web 用 BFF の応答です(整形して表示。5件のうち2件だけ載せています)。

{
    "user": {
        "name": "山田 花子",
        "email": "hanako@example.com",
        "rank": "GOLD",
        "points": 1280
    },
    "recentOrders": [
        {
            "id": "ORD-1012",
            "status": "SHIPPED",
            "orderedAt": "2026-10-16T09:30:00Z",
            "total": 7194,
            "items": [
                "ワイヤレスマウス ×1",
                "USB-C ケーブル 1m ×2"
            ]
        },
        {
            "id": "ORD-1011",
            "status": "DELIVERED",
            "orderedAt": "2026-10-15T09:30:00Z",
            "total": 7194,
            "items": [
                "ワイヤレスマウス ×1",
                "USB-C ケーブル 1m ×2"
            ]
        }
    ]
}

スマホ用 BFF(ポート 25220・X-Requested-By: mobile)の応答です。同じ2つのサービスから、名前と3件だけを返します。

{
    "name": "山田 花子",
    "orders": [
        {
            "id": "ORD-1012",
            "status": "SHIPPED",
            "total": 7194
        },
        {
            "id": "ORD-1011",
            "status": "DELIVERED",
            "total": 7194
        },
        {
            "id": "ORD-1010",
            "status": "PROCESSING",
            "total": 7194
        }
    ]
}

比べるために、BFF を通さず注文サービスを直接呼ぶと、1件目の注文だけで次の量があります(これが12件届きます)。

{
  "id": "ORD-1012",
  "userId": 1,
  "status": "SHIPPED",
  "orderedAt": "2026-10-16T09:30:00Z",
  "items": [
    { "sku": "SKU-100", "name": "ワイヤレスマウス", "price": 3980, "qty": 1, "warehouse": "TKY-02" },
    { "sku": "SKU-200", "name": "USB-C ケーブル 1m", "price": 1280, "qty": 2, "warehouse": "TKY-02" }
  ],
  "subtotal": 6540, "shippingFee": 0, "tax": 654, "total": 7194,
  "shipping": { "method": "STANDARD", "carrier": "SampleExpress", "trackingNo": "TRK900000", "zip": "100-0001", "address": "東京都千代田区1-1-1 サンプルビル 5F" },
  "payment": { "method": "CARD", "brand": "VISA", "last4": "4242", "authCode": "AUTH0" }
}

リクエスト回数と応答サイズを比べる

1画面を出すのに、画面側が送るリクエストの回数・受け取るバイト数・かかる時間を数えるスクリプトです。ログインは計測に含めません。

// compare.mjs  BFFなし(画面がバックエンドを直接呼ぶ)とBFFありで、1画面分の回数・サイズ・時間を比べる
let count = 0, bytes = 0;
async function get(url, opts = {}) {
  const r = await fetch(url, opts);
  const text = await r.text();
  count++; bytes += Buffer.byteLength(text);
}
async function measure(label, fn) {
  count = 0; bytes = 0;
  const t0 = performance.now();
  await fn();
  console.log(`${label.padEnd(18)} requests=${count}  bytes=${String(bytes).padStart(5)}  time=${Math.round(performance.now() - t0)}ms`);
}
const cred = JSON.stringify({ loginId: "yamada", password: "pass" });

// BFFなし:ブラウザがトークンを持ち、バックエンドを直接呼ぶ(ログインは計測の外)
const t = await (await fetch("http://127.0.0.1:25201/token", { method: "POST", body: cred })).json();
const h = { headers: { Authorization: `Bearer ${t.access_token}` } };
await measure("no BFF, serial", async () => {
  await get("http://127.0.0.1:25201/users/me", h);
  await get(`http://127.0.0.1:25202/orders?userId=${t.user_id}`, h);
});
await measure("no BFF, parallel", async () => {
  await Promise.all([get("http://127.0.0.1:25201/users/me", h), get(`http://127.0.0.1:25202/orders?userId=${t.user_id}`, h)]);
});

// BFFあり:ブラウザはCookieだけを持ち、BFFを1回呼ぶ
for (const [client, port] of [["web", 25210], ["mobile", 25220]]) {
  const hdr = { "X-Requested-By": client };
  const login = await fetch(`http://127.0.0.1:${port}/login`, { method: "POST", headers: hdr, body: cred });
  const cookie = login.headers.get("set-cookie").split(";")[0];
  await measure(`${client} BFF`, async () => {
    await get(`http://127.0.0.1:${port}/api/home`, { headers: { ...hdr, Cookie: cookie } });
  });
}
node compare.mjs
no BFF, serial     requests=2  bytes= 7428  time=212ms
no BFF, parallel   requests=2  bytes= 7428  time=108ms
web BFF            requests=1  bytes=  884  time=107ms
mobile BFF         requests=1  bytes=  190  time=107ms
2→1回1画面あたりのリクエストサービスが増えるほど差が開く
190バイトスマホが受け取る量BFF なしは 7,428 バイト、Web 用 BFF は 884 バイト
約107ミリ秒BFF の応答時間奥の2本を並列に呼んだ結果

3回続けて実行した結果は、BFF なし(順番に呼ぶ)が 209〜213 ミリ秒、BFF なし(並列)が 105〜112 ミリ秒、Web 用 BFF が 106〜107 ミリ秒、スマホ用 BFF が 107〜108 ミリ秒でした。読み取れることは次のとおりです。

  • 受け取る量:BFF なしでは、ユーザー 541 バイトと注文 6,887 バイトの計 7,428 バイトが届きます。Web 用 BFF は 884 バイト(約8分の1)、スマホ用 BFF は 190 バイト(約39分の1)でした。
  • 回数:BFF なしは2回、BFF ありは1回です。画面に必要なサービスが増えても、画面からの回数は1回のままです。
  • 時間:この計測はすべて同じマシンの中で、通信の往復にかかる時間がほぼゼロです。そのため、BFF の時間は「並列に直接呼ぶ」場合とほぼ同じでした。差が出たのは「順番に呼ぶ」場合で、BFF が奥で並列に呼ぶ分、約半分になりました。往復に時間のかかる回線では、画面からの回数が減る分の差が加わります。
  • BFF が1段増える分:BFF を通すと、通信の段(ホップ)が1つ増えます。Microsoft のドキュメントも、遅延が増えうる点を注意点に挙げています。

BFFでの認証:トークンをブラウザに渡さない

ブラウザで動く画面(SPA)の認証では、アクセストークンを BFF が持ち、ブラウザには HttpOnly の Cookie だけを渡す形があります。 2026年8月に RFC 10017(OAuth 2.0 for Browser-Based Applications、BCP 212)として公開された文書の 6.1 節が、この BFF の形を説明しています。ブラウザにはトークンが無いので、画面に悪意のあるスクリプトが入り込んでも、トークンそのものは盗めません。

flowchart TD
    B[ブラウザ<br>Cookieだけ持つ] -->|1 ログイン・画面の要求| F[BFF<br>トークンを持つ]
    F -->|2 トークンを付けて呼ぶ| U[認可サーバー・API]
    U -->|3 データ| F
    F -->|4 画面の形にして返す| B

ログインの応答に、トークンは入っていない

上の bff.mjs にログインしたときの応答です。ブラウザに届くのは {"ok":true} と Cookie だけで、トークンは BFF のメモリ(sessions)の中にあります。

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 11
Set-Cookie: __Host-Http-sid=92b845a7045a436737dc5878cac9b9a1; Path=/; Secure; HttpOnly; SameSite=Strict
Date: Fri, 16 Oct 2026 22:57:07 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"ok":true}

BFF なしの構成では、ブラウザが次のようなトークンを受け取り、自分で持ち歩くことになります(ユーザーサービスの /token の応答から取り出したもの)。

eyJzdWIiOjEsImV4cCI6MTc5MjE5MjIyMn0.1ObTY36KYlum0gPQEr3MICwyU9j4pmpsYp6L7slt_EQ

RFC 10017 が BFF に求めること

項目 RFC 10017 の要求 この例での書き方
BFF の立場 機密クライアントとして、認可コードで取得(MUST) ログインは BFF が代わりに行う(例では簡略化)
Cookie の Secure・HttpOnly 付ける(MUST) Secure; HttpOnly
Cookie の SameSite Strict にする(SHOULD) SameSite=Strict
Cookie 名の接頭辞 __Host-Http- を付ける(SHOULD) __Host-Http-sid
CSRF 対策 必ず行う(MUST)。CORS と独自ヘッダーの組み合わせを推奨 X-Requested-By が無いと 403
中継する宛先 許可した宛先だけに送る(MUST) USERS・ORDERS の固定URLだけ

MDN によると、HttpOnly は JavaScript から Cookie を読めなくする属性です。__Host-Http- で始まる名前の Cookie は、HTTPS で Secure と HttpOnly を付け、Domain を付けず Path=/ で設定したものしかブラウザが受け付けません。接頭辞に対応していないブラウザでは、この追加の保証は働かないとも書かれています。

例のログインは、話を短くするためにパスワードを BFF 経由でユーザーサービスへ渡しています。RFC 10017 の形では、BFF は OAuth の認可コードフローで認可サーバーからトークンを受け取ります。

実際に返ったコード

試したこと 返ったコードと本文
Cookie はあるが X-Requested-By が無い 403 {"error":"missing X-Requested-By"}
X-Requested-By はあるが Cookie が無い 401 {"error":"not logged in"}
注文サービスをトークンなしで直接呼ぶ 401 {"error":"invalid_token"}
末尾を1文字変えたトークンで直接呼ぶ 401 {"error":"invalid_token"}
ログアウト後に同じ Cookie を送る 401 {"error":"not logged in"}

ログアウトでは、BFF がセッションを消し、Max-Age=0 の Cookie を返して、ブラウザ側の Cookie も消させます。

HTTP/1.1 200 OK
Set-Cookie: __Host-Http-sid=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0

関連記事「Cookieとセッションの違いと仕組み|セッションIDの流れを図解」で扱っています。

関連記事「CSRF対策とSameSite|Cookie送信をChromeで実測し多層防御を実装」で扱っています。

バックエンド障害時のBFFのふるまい

BFF は複数のサービスを呼ぶので、1つが落ちたときに画面全体を止めるか、残りで返すかを決めておきます。Newman 氏は、在庫サービスが落ちたら在庫の表示だけを外すように、BFF は全体を失敗させずに機能を落として返すべきだとしています。画面の側も、一部が欠けた応答を表示できる作りにします。

bff.mjs は Promise.allSettled で両方の結果を待ち、失敗した方を null にして degraded に名前を入れます。バックエンド1本あたりの待ち時間は 800 ミリ秒です。

curl -s -b jar.txt -H 'X-Requested-By: web' \
  -w '\nstatus=%{http_code} time=%{time_total}s\n' http://127.0.0.1:25210/api/home

注文サービスを止めたとき(接続を断られる)の出力です。

{"user":{"name":"山田 花子","email":"hanako@example.com","rank":"GOLD","points":1280},"recentOrders":null,"degraded":["orders"]}
status=200 time=0.103295s

注文サービスを、応答に 2,000 ミリ秒かかる設定(DELAY=2000)で起動し直したときの出力です。800 ミリ秒で待つのをやめ、ユーザー情報だけで返しました。

{"user":{"name":"山田 花子","email":"hanako@example.com","rank":"GOLD","points":1280},"recentOrders":null,"degraded":["orders"]}
status=200 time=0.805169s

さらにユーザーサービスも止めると、返せるものが無いので 503 にしています。

{"user":null,"recentOrders":null,"degraded":["user","orders"]}
status=503 time=0.801872s

このときの BFF のログです。接続できないときは fetch failed、待ち時間切れは The operation was aborted due to timeout が出ました。

[bff-web] orders: fetch failed
[bff-web] /api/home 103ms
[bff-web] orders: The operation was aborted due to timeout
[bff-web] /api/home 802ms
[bff-web] user: fetch failed
[bff-web] orders: The operation was aborted due to timeout
[bff-web] /api/home 801ms

まとめると、次のようになりました。

状況 BFF の応答 かかった時間
両方とも正常 200(全項目) 約107ミリ秒
注文サービスが停止 200(注文だけ null・degraded) 0.10秒
注文サービスが2秒かかる 200(注文だけ null・degraded) 0.81秒
両方とも返せない 503 0.80秒
待ち時間を 10,000 ミリ秒にして注文が2秒 200(全項目) 2.02秒

最後の行は、TIMEOUT_MS=10000 node bff.mjs web で待ち時間を長くした場合です。BFF の応答は、いちばん遅いバックエンドに引きずられます。 待ち時間を決めておかないと、1つのサービスの遅れがそのまま画面の遅れになります。障害が続くサービスを一定時間呼ばないようにする仕組み(サーキットブレーカー)を足す構成もあります。

関連記事「サーキットブレーカーとは|3状態の実装とリトライ可否の一覧表」で扱っています。

関連記事「タイムアウト設計の値の決め方|接続・読み取りの違いと多段の計算例」で扱っています。

BFFを使う場面・使わない場面(判断表)

Newman 氏と Microsoft のドキュメントの記述をもとにした判断表です。

状況 BFF 理由
Web とスマホで、同じ画面でも欲しい項目や件数が違う 向いている 画面ごとの形を BFF で作れる
1画面のために複数のサービスを呼ぶ 向いている 集約と並列呼び出しを BFF に寄せられる
SPA で、ブラウザにトークンを置きたくない 向いている RFC 10017 の BFF の形
画面のチームが、バックエンドの変更待ちで止まりやすい 向いている 画面のチームが BFF を持てば自分で変えられる
汎用のバックエンドの保守に手間がかかっている 向いている Microsoft が使う場面に挙げている
画面が1種類だけ 不要なことが多い Microsoft が使わない場面に挙げている
どの画面も同じデータを同じ形で求める 不要なことが多い Microsoft が使わない場面に挙げている
画面ごとのリゾルバーを持つ GraphQL がある 価値が小さいことがある 画面が項目を選べる
全画面共通の認証確認・流量制限だけをしたい API Gateway の役割 共通の処理は BFF に置かない

Newman 氏は、スマホアプリや外部の利用者がいる場合は最初から BFF を検討し、Web だけの場合はサーバー側で多くの集約が要るときに意味があるとしています。

BFFの注意点(デメリット)とよくある失敗

Microsoft のドキュメントは、注意点として、サービスが増えて運用の手間が増えること、ネットワークの段が1つ増えて遅延が増えうること、画面ごとの BFF でコードの重複が起きやすいことを挙げています。よくある失敗と、ドキュメントに沿った形を並べます。

失敗 1注文の確定・在庫の引き当てなど、業務のルールを BFF に書く
→
沿った形業務のルールは奥のサービスに置き、BFF は集約と整形だけにする

Microsoft:BFF は画面固有の処理だけを扱う

失敗 2すべての画面で1つの BFF を共有し、条件分岐で太らせる
→
沿った形体験1つに BFF 1つ。似た画面を同じチームが持つ場合は共有もありうる

Newman:共有した BFF は肥大化しやすい

失敗 3認証確認・流量制限・監視を BFF ごとに書く
→
沿った形全画面共通の処理は API Gateway などに寄せる

Microsoft:共通の処理はゲートウェイ側のパターンで扱う

失敗 4奥のサービスを順番に、待ち時間なしで呼ぶ
→
沿った形依存しない呼び出しは並列にし、1本ごとに待ち時間を決める

実測:待ち時間 800 ミリ秒で 0.81秒、長くすると 2.02秒

失敗 5受け取った URL へそのまま中継する、汎用のプロキシにする
→
沿った形呼び出し先を許可したものだけに決めておく

RFC 10017 6.1.3.6:許可した宛先だけに転送する

よくある質問

Q. BFFとは何ですか? A. Backend for Frontend の略で、Web・スマホなど画面の種類ごとに置く専用のバックエンドです。複数のサービスからデータを集め、その画面に必要な形にして返します。「BFF(Backend for Frontend)とは」の節で説明しています。

Q. bff の意味は? A. システム開発では Backend for Frontend(Backends For Frontends)の略です。日常の会話や SNS では Best Friends Forever(ずっと親友)の略として使われます。

Q. BFF アーキテクチャはどんな構成ですか? A. 画面 → (API Gateway)→ 画面ごとの BFF → 各サービスの順に並べる構成が一般的です。「BFFのアーキテクチャ」の図にあります。

Q. BFF と API Gateway の違いは何ですか? A. API Gateway は全体の共通の入口で、認証の確認や流量制限などを受け持ちます。BFF は画面ごとの専用サーバーで、集約と整形を受け持ちます。併用する構成が多く、「違い」の表で比べています。

Q. GraphQL を使えば BFF は不要ですか? A. 画面ごとのリゾルバーを持つ GraphQL があれば、BFF の価値は小さくなることがあると Microsoft は書いています。トークンをブラウザに置かない目的なら、BFF を置く理由は残ります。

Q. BFF は誰が作りますか? A. Newman 氏・Microsoft とも、その画面を作るチームが持つ形を示しています。画面の都合で API を変えるときに、別のチームを待たずに済むためです。

まとめ

  • BFF は画面の種類ごとに置く専用のバックエンドで、集約・整形・セッションを受け持つ。
  • API Gateway は全体の共通の入口、GraphQL は問い合わせの方式で、BFF とは役割が違う。併用もできる。
  • Node.js の実測では、1画面あたり2回・7,428バイトが、Web 用 BFF で1回・884バイト、スマホ用 BFF で1回・190バイトになった。
  • トークンは BFF が持ち、ブラウザには Secure・HttpOnly・SameSite=Strict の Cookie だけを渡す(RFC 10017)。
  • 1つのサービスが落ちても、BFF は残りで返せる。待ち時間を決めないと、いちばん遅いサービスに引きずられる。
  • 画面が1種類だけ・どの画面も同じデータなら、BFF は不要なことが多い。

参考・出典

確認日はいずれも 2026年10月17日です。

  • Sam Newman「Pattern: Backends For Frontends」:https://samnewman.io/patterns/architectural/bff/
  • Microsoft Azure アーキテクチャセンター「Backends for Frontends pattern」:https://learn.microsoft.com/en-us/azure/architecture/patterns/backends-for-frontends
  • RFC 10017「OAuth 2.0 for Browser-Based Applications」:https://www.rfc-editor.org/info/rfc10017
  • IETF Datatracker「OAuth 2.0 for Browser-Based Applications」:https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
  • MDN「Set-Cookie」:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie
  • GraphQL「Learn」:https://graphql.org/learn/
  • Node.js「AbortSignal.timeout」:https://nodejs.org/api/globals.html#static-method-abortsignaltimeoutdelay

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「BFF(Backend for Frontend)とは|構成図とNode.js実装」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?