はじめに

DX開発事業部 深川です。
Google Cloud CDN には「署名付き URL」と「署名付き Cookie」の 2 種類のアクセス制御機能があります。本稿では署名付き Cookie を Next.js (App Router) + Cloud Run 構成のシステムに導入した際の実装方針を紹介します。

システム構成

GCS バケットは public_access_prevention = enforced で完全非公開です。画像は必ずバックエンド経由で配信し、Cloud CDN がエッジでキャッシュします。

  • ブラウザ → Cloud Load Balancing(HTTPS)
  • /api/v1/images/* → backend Cloud Run (FastAPI) → GCS(SA 認証・非公開バケット)
  • /* → frontend Cloud Run (Next.js)

署名付き URL ではなくCookie を選んだ理由は、1 ページに数十枚の画像が並ぶ一覧画面で URL を個別に署名するコストを避けるためです。Cookie を 1 回セットすれば、それ以降すべての画像リクエストに署名が乗ります。

実装

1. CDN 側の設定(Terraform)

バックエンドサービスに署名キーを登録します。キー自体は Terraform 管理外にしています。キー値は Secret Manager に保存し、ローテーションスクリプトが直接 CDN API を叩く設計です(後述)。

resource "google_compute_backend_service" "backend_images" {
  name       = "${var.env}-backend-images"
  enable_cdn = true
  cdn_policy {
    cache_mode                   = "USE_ORIGIN_HEADERS"
    signed_url_cache_max_age_sec = 3600
    negative_caching             = true
  }
}

2. Cookie の生成(Next.js Edge Runtime)

Cloud CDN の署名付き Cookie は Cloud-CDN-Cookie という固定名で、値の構造は次の通りです。

URLPrefix={base64url(prefix)}:Expires={unix_time}:KeyName={key_name}:Signature={hmac_sha1}

Edge Runtime では Node.js の crypto モジュールが使えないため、Web Crypto API で HMAC-SHA1 を実装します。HMAC-SHA1 は現代的には非推奨ですが、Cloud CDN の署名方式がこれを要求しているため変更不可です。

セキュリティの「最小特権の原則」に基づき、urlPrefix はドメイン全体ではなく、画像配信ルートである /api/v1/images/ に限定します。

const TTL_SECONDS = 8 * 3600
const REFRESH_THRESHOLD_MS = 30 * 60 * 1000

export async function buildCdnCookieValue(domain: string): Promise<string> {
  const keyName = process.env.CDN_KEY_NAME ?? 'dev-cdn-signing-key'
  const keyBase64 = await getSigningKey()
  const expiresAt = Math.floor(Date.now() / 1000) + TTL_SECONDS

  // セキュリティ向上のため、画像配下のパスにのみマッチさせる
  const urlPrefix = `https://${domain}/api/v1/images/`
  const urlPrefixB64 = btoa(urlPrefix)
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')

  const message = `URLPrefix=${urlPrefixB64}:Expires=${expiresAt}:KeyName=${keyName}`

  const base64Key = keyBase64.replace(/-/g, '+').replace(/_/g, '/') + '=='
  const keyBytes = Uint8Array.from(atob(base64Key), (c) => c.charCodeAt(0))
  const msgBytes = new TextEncoder().encode(message)

  const cryptoKey = await crypto.subtle.importKey(
    'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
  )
  const sigBuffer = await crypto.subtle.sign('HMAC', cryptoKey, msgBytes)
  const signature = base64urlEncode(sigBuffer)

  return `${message}:Signature=${signature}`
}

3. 署名キーの動的取得(Cloud Run 内)

Cloud Run環境ではキー値を Secret Manager から動的に取得し、モジュールレベルで 1 時間キャッシュします。Edge Runtime は Node.js SDK が動作しないため、metadata server からプロジェクトIDとアクセストークンを取得して Secret Manager REST API を直接呼びます。

let cachedKey: { value: string; expiresAt: number } | null = null

async function getSigningKey(): Promise<string> {
  if (cachedKey && cachedKey.expiresAt > Date.now()) {
    return cachedKey.value
  }

  if (!process.env.K_SERVICE) {
    return process.env.CDN_SIGNING_KEY ?? ''
  }

  // メタデータサーバーからプロジェクトIDとトークンを動的に取得
  const [projectRes, tokenRes] = await Promise.all([
    fetch('http://metadata.google.internal/computeMetadata/v1/project/project-id', { headers: { 'Metadata-Flavor': 'Google' } }),
    fetch('http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token', { headers: { 'Metadata-Flavor': 'Google' } })
  ])

  const projectId = await projectRes.text()
  const { access_token } = await tokenRes.json()

  const smRes = await fetch(
    `https://secretmanager.googleapis.com/v1/projects/${projectId}/secrets/cdn-signing-key/versions/latest:access`,
    { headers: { Authorization: `Bearer ${access_token}` } }
  )
  const { payload } = await smRes.json()

  const cdnKey = atob(payload.data.replace(/-/g, '+').replace(/_/g, '/'))
  cachedKey = { value: cdnKey, expiresAt: Date.now() + 3_600_000 }
  return cachedKey.value
}

4. Cookie の発行(middleware)

Next.js の middleware で、認証済みリクエストのたびに Cookie の残り有効期限を確認し、30 分を切ったら再発行します。Cookie 値には := が多数含まれるため、response.cookies.set() は使わず response.headers.append() で生の Set-Cookie ヘッダーを直接書きます

また、不要なリクエストへの Cookie 送信を防ぐため、Path 属性も /api/v1/images/ に絞り込みます。

if (accessToken) {
  const cdnCookie = request.cookies.get(COOKIE_NAME)?.value
  const remainingMs = getRemainingTtlMs(cdnCookie)
  const needsRefresh = remainingMs < REFRESH_THRESHOLD_MS

  if (needsRefresh) {
    const domain =
      request.headers.get('x-forwarded-host') ??
      request.headers.get('host')?.split(':')[0] ??
      request.nextUrl.hostname

    const cookieValue = await buildCdnCookieValue(domain)
    // Path属性を画像APIパスに限定し、不要なブラウザ送信を抑制
    response.headers.append(
      'Set-Cookie',
      `${COOKIE_NAME}=${cookieValue}; Path=/api/v1/images/; HttpOnly; Secure; SameSite=Lax; Max-Age=${8 * 3600}`
    )
  }
}

Cloud Run 内部では nextUrl.hostname0.0.0.0 になるため、X-Forwarded-HostHost の順でフォールバックして正しいドメインを取得しています。

5. キーローテーション

CDN 署名キーを月次でローテーションします。既存の Cookie を無効にしないようにするため、2 つのジョブに分けて実行します。

【シームレスなローテーションの流れ】
 0:00 (Job 1) ── 新キーを生成し、Secret Manager の「latest」を更新
                 CDN 側には「旧キー」と「新キー(-new)」が並存する状態を作る
      │
      ▼ (Next.js は latest を見にいくため、この9時間で新Cookieが順次発行される)
      │
 9:00 (Job 2) ── CDN から旧キーを削除。新キーの登録名を正式名にリネーム

毎月1日の深夜0時に最初のジョブが起動します。新しい署名キーを生成して Secret Manager の latest バージョンを更新し、CDN には {KEY_NAME}-new という一時的な名前で登録します。この時点で CDN は旧キーと新キーの両方を受け付けるため、既存の Cookie はそのまま有効です。

Next.js 側は Secret Manager の latest を動的に参照しているため、0時以降に新規発行される Cookie は自动的に新キーで署名されます。CDN 側が一時キー名(-new)でも検証をパスしてくれるため、エラーは発生しません。

9時間後の午前9時に2番目のジョブが起動します。CDN から旧キーを削除して新キーを正式名 {KEY_NAME} で登録し直し、一時キーと古いシークレットバージョンも削除します。2 つのジョブの間隔を Cookie の有効期限(8時間)より長く設定することで、ローテーション中に画像が 403 になるリスクを完全に回避しています。

Cloud Scheduler で自動化しており、ローテーションジョブは Cloud Run Job として実装しています。ROTATE_STEP=add / ROTATE_STEP=remove の環境変数で処理を切り替えることで、同一コンテナイメージで両ステップを担います。

resource "google_cloud_scheduler_job" "rotate_cdn_key_add" {
  schedule  = "0 0 1 * *"
  time_zone = "Asia/Tokyo"
}

resource "google_cloud_scheduler_job" "rotate_cdn_key_remove" {
  schedule  = "0 9 1 * *"
  time_zone = "Asia/Tokyo"
}

まとめ

実装ポイント 詳細
署名アルゴリズム Cloud CDN は HMAC-SHA1 固定。Edge Runtime では Web Crypto API で実装
キー取得 Cloud Run 内では Secret Manager REST API から動的取得(1 時間キャッシュ)
Cookie 発行 response.headers.append('Set-Cookie', ...) で生ヘッダーを書く
スコープ制限 URLPrefixPath/api/v1/images/ に絞り、安全性を最大化
発行タイミング middleware で残り 30 分を切ったら再発行(8 時間 TTL)
ローテーション Secret Manager の latest 更新と CDN の並存期間(9 時間)を組み合わせた 2 ステップ方式

署名付き URL と比べて Cookie 方式は「1 回セットすれば複数リソースに適用できる」点が優れています。一方でキーローテーション時の無停止維持や、Edge Runtime の制約への対応など、考慮すべき点も多くあります。本稿が Cloud CDN を活用した画像アクセス制御の実装の参考になれば幸いです。