はじめに
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.hostname が 0.0.0.0 になるため、X-Forwarded-Host → Host の順でフォールバックして正しいドメインを取得しています。
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', ...) で生ヘッダーを書く |
| スコープ制限 | URLPrefix と Path を /api/v1/images/ に絞り、安全性を最大化 |
| 発行タイミング | middleware で残り 30 分を切ったら再発行(8 時間 TTL) |
| ローテーション | Secret Manager の latest 更新と CDN の並存期間(9 時間)を組み合わせた 2 ステップ方式 |
署名付き URL と比べて Cookie 方式は「1 回セットすれば複数リソースに適用できる」点が優れています。一方でキーローテーション時の無停止維持や、Edge Runtime の制約への対応など、考慮すべき点も多くあります。本稿が Cloud CDN を活用した画像アクセス制御の実装の参考になれば幸いです。