plan-4ging

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  ← ランダムトークンを含める