2026/04/11
API Bypass とは
概要
「ブラウザから外部 API を直接呼ばずに、サーバー側(Next.js etc.)を経由して呼び出す設計パターン」を指す
Bypass なし(ブラウザが直接外部APIを呼ぶ)
ブラウザ → 外部 API(外部サービス(Slack etc.)/自社別サービス など)
問題点:
- API キーがブラウザの JS に含まれて誰でも見られる
- 外部 API の CORS 設定に依存する
- 外部 API のレスポンス形式がそのままフロントに届く
- レートリミット、エラーハンドリングを外部 API に依存する
Bypass あり(サーバー経由で外部APIを呼ぶ)
ブラウザ → BFF サーバー(Next.js etc.) → 外部 API
メリット:
- API キーはサーバーサイドのみに置ける(ブラウザに漏れない)
- CORS の問題が発生しない(サーバー間通信は CORS 対象外)
- レスポンスの整形、フィルタリングをサーバーで行える
- 外部 API への依存をサーバー側に閉じ込められる
意義
下記情報を守ることができる
API キー・シークレット
→ ブラウザの JS に含めると DevTools で誰でも見られる
内部サービスの URL/構造
→ 社内 API のエンドポイント、IP アドレス
→ 攻撃者に内部構造を知られると攻撃の糸口になる
ユーザーに見せてはいけないデータ
→ 外部 API が全フィールドを返しても
サーバーで必要なフィールドだけに絞ってから返せる
また下記サーバー経由にすることで下記のような制御を得られる
・リクエストの検証、バリデーション
・レートリミット、キャッシュの実装
・ログ/モニタリングの一元管理
・外部 API の変更をサーバー側だけで吸収できる
対象
フロント(bff)から外部 API を呼び出す場合
フロント(ブラウザ) → BFF サーバー(Next.js etc.) → 外部 API
ブラウザから外部 API を直接呼ぶ場合...
・API キーがブラウザに露出する可能性
・CORS の制約を受ける
→ BFF サーバーを挟んで Bypass する必要あり
外部 API から BFF サーバーを呼び出す場合は対象外
外部 API → BFF サーバー(Next.js etc.)
外部サービスが Webhook 等でこちら側の BFF に送ってくる場合は...
・サーバー間通信のため CORS は関係ない
・API キーの露出の問題もない
→ Bypass は必要ない
利用用途
| 用途 | 主な目的 | 主な対象 |
|---|---|---|
| CORS 対策 | ブラウザの CORS 制限を回避 | 外部 API(自社外) |
| API キー隠蔽 | シークレットをブラウザに渡さない | 外部 API |
| 内部認証付き Bypass | ユーザー認証 + サービス間認証の連携 | 自社マイクロサービス |
| レスポンス整形 | 不要フィールドの除去/API 集約 | 複数サービスの統合 |
| レートリミット | ユーザーごとの呼び出し制限 | コストが発生する外部 API |
| キャッシュ | レイテンシ削減/外部 API 保護 | 更新頻度が低いデータ |
CORS 対策
外部 API が自社ドメインからのリクエストを許可していない場合に問題になる
ブラウザ
→ fetch({外部 API})
→ CORS エラー
一旦同一オリジンの BFF を挟むことでサーバー間の HTTP 通信となり、CORS が適用されない
ブラウザ
→ fetch({BFF サーバー(Next.js etc.)})
→ 同一オリジンなので CORS なし
→ fetch({外部 API}) ←
→ CORS エラーなし(サーバー間通信は CORS 対象外)
API Key/Secretの隠蔽
ブラウザに API キーを露出させない
→ クライアント(ブラウザ)側の JS に API キーを書くと DevTools で誰でも見られる
BFF サーバーのみが API キーを持ち、ブラウザはキーを知らずにリソースを取得できる
// app/api/proxy/hoge/route.ts
// API キーはサーバーサイドの環境変数にのみ存在する
const API_KEY = process.env.API_KEY! // ブラウザには渡らない
export async function POST(request: NextRequest) {
...
}
内部認証付き Bypass(Internal Auth)
ユーザーセッションを検証してから、サービス間シークレットを付与して転送する
- ブラウザは内部シークレットを知らない
- 内部サービスはブラウザから直接叩けない
// app/api/proxy/internal/[service]/route.ts
const INTERNAL_SECRET = process.env.INTERNAL_API_SECRET!
const ALLOWED_SERVICES: Record<string, string> = {
'user-service': process.env.USER_SERVICE_URL!,
'order-service': process.env.ORDER_SERVICE_URL!,
}
export async function GET(
request: NextRequest,
{ params }: { params: { service: string } }
) {
// ユーザーセッション検証
const session = await verifySession(request)
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 内部サービスのホワイトリスト確認
const baseUrl = ALLOWED_SERVICES[params.service]
if (!baseUrl) {
return NextResponse.json({ error: 'Unknown service' }, { status: 404 })
}
// 内部シークレット + ユーザー情報を付与して転送
const response = await fetch(`${baseUrl}${new URL(request.url).search}`, {
headers: {
'X-Internal-Secret': INTERNAL_SECRET, // サービス間認証
'X-User-ID': session.userId, // ユーザー情報の伝播
'X-User-Role': session.role,
},
})
return NextResponse.json(await response.json())
}
レスポンスのフィルタリング/整形
外部 API の生レスポンスをそのままブラウザに返さず必要な情報だけ返す
- 不必要なフィールドをブラウザに見せない
- 複数 API のレスポンスをひとまとめにする(API Composition)
- 外部 API の仕様変更をサーバー側だけで吸収する
// app/api/proxy/user-profile/route.ts
// 複数の内部サービスを集約して返す(API Composition)
export async function GET(request: NextRequest) {
...
// 複数サービスを並列で呼ぶ
const [userRes, orderRes] = await Promise.all([
fetch(`${USER_SERVICE_URL}/users/${session.userId}`, { headers: internalHeaders(session) }),
fetch(`${ORDER_SERVICE_URL}/orders?userId=${session.userId}`, { headers: internalHeaders(session) }),
])
const [user, orders] = await Promise.all([userRes.json(), orderRes.json()])
// 必要なフィールドのみ返す(不要フィールド除外)
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
orderCount: orders.total,
})
}
レートリミット・アクセス制御
外部 API へのアクセス頻度を BFF 側で制御する
- 呼び出し回数を制限する
- API の利用コストを抑える
- API のレートリミットに引っかからないようにする
// app/api/proxy/hoge/route.ts
const rateLimiter = new RateLimit({ window: '1m', max: 10 }) // 1分に 10回
export async function POST(request: NextRequest) {
...
// レートリミット適用
const { success } = await rateLimiter.check(session.userId)
if (!success) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
}
// API 呼び出し
...
}
キャッシュ
外部 API の結果を BFF 側でキャッシュしてレイテンシを下げる
// app/api/proxy/weather/route.ts
export async function GET(request: NextRequest) {
const city = new URL(request.url).searchParams.get('city') ?? 'Tokyo'
const cacheKey = `weather:${city}`
const cached = await redis.get(cacheKey)
// Cache ヒット
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: { 'X-Cache': 'HIT' }
})
}
// Cache ミス
const response = await fetch(`{外部 API}`)
const data = await response.json()
// Cache セット
await redis.setex(cacheKey, 600, JSON.stringify(data))
return NextResponse.json(data, { headers: { 'X-Cache': 'MISS' } })
}
注意点/その他活用
Open Proxy を作らない
// NG:任意の URL に転送するオープンプロキシ
// 攻撃者が内部サービスの URL を指定してアクセスできてしまう(SSRF 攻撃)
export async function POST(request: NextRequest) {
const { url, ...body } = await request.json()
const response = await fetch(url, { method: 'POST', body: JSON.stringify(body) })
return NextResponse.json(await response.json())
}
// OK:ホワイトリストで転送先を制限する
const ALLOWED_ENDPOINTS: Record<string, string> = {
'xxxxx': 'xxxxx',
}
export async function POST(request: NextRequest) {
const { endpoint, ...body } = await request.json()
const targetUrl = ALLOWED_ENDPOINTS[endpoint]
if (!targetUrl) {
return NextResponse.json({ error: 'Unknown endpoint' }, { status: 400 })
}
const response = await fetch(targetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return NextResponse.json(await response.json())
}
タイムアウト/リトライ設定
// lib/external-fetch.ts
// タイムアウト・リトライ付きの外部 API 呼び出しラッパー
interface FetchOptions extends RequestInit {
timeout?: number
maxRetries?: number
retryDelay?: number
}
export async function fetchWithRetry(
url: string,
options: FetchOptions = {}
): Promise<Response> {
const {
timeout = 10000, // デフォルト 10秒
maxRetries = 3,
retryDelay = 1000,
...fetchOptions
} = options
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
})
// 5xx エラーはリトライ対象
if (response.status >= 500 && attempt < maxRetries) {
const delay = retryDelay * Math.pow(2, attempt) // Exponential Backoff
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
return response
} catch (error) {
if (attempt === maxRetries) throw error
if ((error as Error).name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms: ${url}`)
}
const delay = retryDelay * Math.pow(2, attempt)
await new Promise(resolve => setTimeout(resolve, delay))
} finally {
clearTimeout(timeoutId)
}
}
throw new Error('Max retries exceeded')
}
補足
外部 API → BFF へのアクセス時に注意すべき点
署名(Signature)の検証
外部サービスから届いたリクエストが「本物かどうか」を必ず検証する
署名検証なしで処理すると、攻撃者が Webhook に見せかけたリクエストを送って「支払い完了」などのイベントを偽装できてしまう
冪等性(Idempotency)の確保
Webhook は通信障害等で同じイベントが複数回届くことがある
同じイベントを二重処理しないように設計する
タイムアウト対策(即時レスポンス)
外部サービスは一定時間内に200が返らないとリトライしてくる
重い処理はキューに積んで即座に200または202を返す設計にする
エンドポイントの秘匿
URL 自体を秘密の一部にする(セキュリティの多層防御)
NG:/api/webhooks/example
OK:/api/webhooks/example/a8f3c2d1b9e7 ← ランダムトークンを含める