はじめに
今回は、Amazon CloudFrontの署名付きCookie機能を実際に使ってみたので、その内容をまとめてみました。
少しでも参考になれば幸いです。
導入の背景:
スタンプラリー機能のあるLIFFアプリを実装したが、コンプリート後にダウンロードできるオリジナルGIF画像がS3に配置されており、特定のURL: https://example.com/images/completion-gift-*に直接アクセスすることで第三者にもダウンロードされてしまうおそれがあった
CloudFront署名付きCookieとは
Amazon CloudFrontの制限付きコンテンツ(Restricted Content) 機能の一つです。
アプリケーションサーバー(バックエンド)で、ユーザーに対して以下のCookieを発行します。
- CloudFront-Policy
- CloudFront-Signature
- CloudFront-Key-Pair-Id
※CloudFrontが認識するCookie名は上記で固定である必要があります(でないと認識/検証されない)
ブラウザは以後のリクエストでこれらのCookieを自動送信し、CloudFrontは署名を検証してアクセスを許可します。
「検証の流れ」
ユーザー
↓ ログイン
アプリケーションサーバー
↓
署名付きCookie発行
↓
ブラウザに保存
(その後)
ブラウザ
↓ 自動でCookie送信
CloudFront
↓ 自動で検証
S3の保護コンテンツへのアクセスを許可
※以降、Cookieを持っていれば、ポリシーに合致するすべてのファイルへアクセスできます。
CloudFront署名付きURLとの違い
似た機能に署名付きURLがありますが、こちらは特定の1ファイルを保護する方式になります。
以下の例のように、URL自体に認証情報を埋め込みます。
https://example.com/movie.mp4?
Expires=1780000000&
Signature=xxxx&
Key-Pair-Id=K123456789
アクセスを制限したいファイルが複数ファイルある場合、すべてに対して署名付きURLを発行する必要があります。
比較表
| 署名付きURL | 署名付きCookie | |
|---|---|---|
| 対象 | 単一ファイル | 複数ファイル可 |
| URL | 長くなる | 通常のまま |
| 実装 | 簡単 | やや複雑 |
| 使う場面 | 一時的な共有リンク | 会員向けコンテンツ |
実装の流れ
- RSA鍵ペアを作る
- AWS上で署名付きCookieを設定する
- アプリのバックエンドの実装
1. RSA鍵ペアを作る
CloudFrontの署名付きCookieは、「CloudFrontが検証できるデジタル署名」を利用する必要があります。
公開鍵暗号方式を使用します(共通鍵暗号方式を使うと偽造できてしまうため)
署名を作る側(アプリ)は秘密鍵を保持し、CloudFrontは公開鍵を保持し検証します。
CloudFrontは秘密鍵を持たないため、RSA鍵ペアが必要になります。
RSA鍵ペアとは
「公開鍵暗号方式」で使う2つの鍵の組み合わせで、「公開鍵」と「秘密鍵」のペアで構成されます。
一方の鍵で暗号化したデータは、もう一方の鍵でしか復号できません。
ターミナルで以下のコマンドを実行し、RSA鍵ペアを作成します。
# 秘密鍵(コミット/公開厳禁)
openssl genrsa -out gif_private_key.pem 2048
# 公開鍵(CloudFrontに登録用/公開可)
openssl rsa -pubout -in gif_private_key.pem -out gif_public_key.pem
openssl: OpenSSLコマンドを実行
genrsa: RSA秘密鍵を生成
-out: 出力ファイル名を指定
gif_private_key.pem: 生成される秘密鍵ファイル
2048: 鍵長(2048bit)
→ コマンドを実行したディレクトリに、gif_private_key.pemというファイルが作られます。このファイルには「秘密鍵」と、「公開鍵を生成するための情報」が含まれています。
openssl: OpenSSLコマンドを実行
rsa: RSA鍵を扱う
-pubout: 公開鍵を出力
-in: 入力ファイルを指定
gif_private_key.pem: 秘密鍵ファイル
-out: 出力ファイルを指定
gif_public_key.pem: 公開鍵ファイル
→ 「公開鍵」を含むファイルが作成されます。
2. AWS上で署名付きCookieを設定する
AWSコンソールで手動設定する場合の手順:
AWS CDKを使用して設定する場合の手順:
3. アプリのバックエンドの実装
実際にアプリのサーバー(バックエンド)で署名付きCookieを作成する部分を実装します。
CloudFront署名付きCookieを生成するユーティリティ新規ファイルgifDownloadCookie.tsを作成します。
// ============================================================
// GIF 実体への到達を絞る CloudFront 署名付き Cookie
// ------------------------------------------------------------
// /gif-download ページのチケット検証(gifDownloadTicket.ts)は「ページ」を守るが、
// GIF 実体(/images/completion-gift-*)は S3 + CloudFront の公開静的配信のままで、
// URL が漏れれば誰でも落とせてしまう。そこで CloudFront の Trusted Key Group を使い、
// 「署名付き Cookie を持つリクエストだけが GIF を取得できる」ように配信レイヤーで認可する。
//
// 仕組み(CloudFront 署名付き Cookie / 非対称鍵 RSA):
// - CDK が CloudFront に公開鍵(PublicKey)を登録し、KeyGroup を保護パスの
// behavior に trustedKeyGroups として紐付ける(cdk/lib/constructs/static-site.ts)。
// - 本ユーティリティが「対応する秘密鍵」でカスタムポリシーに署名し、CloudFront が
// 検証できる 3 つの Cookie(CloudFront-Policy / -Signature / -Key-Pair-Id)を作る。
// - redeem.post.ts がチケット検証成功後にこの Cookie を Set-Cookie で返し、以降の
// 同一オリジンの GIF 取得(<img> / fetch)に自動付与される。
//
// 設計上のポイント:
// - HMAC チケット(HS256)の鍵とは別物。チケットは「ページ閲覧の本人性」、本 Cookie は
// 「オブジェクト到達の認可」を担う。秘密鍵は Lambda env 専用で、クライアントへ出さない。
// - Resource はホスト名をワイルドカードにして CloudFront デフォルトドメイン・独自ドメインの
// 双方で同じポリシーが効くようにする(パスは completion-gift- 配下に限定)。
// - TTL はチケット(120s)より長く取る。ページは sessionStorage で会話を引き継ぎ、
// チケットを URL から消すため、redeem は基本 1 回だけ。Cookie が閲覧/保存セッション全体を
// 賄えるよう数十分単位にする(失効後はアプリ内 CTA から再発行で再取得できる)。
// ============================================================
import { getSignedCookies } from '@aws-sdk/cloudfront-signer'
/**
* 署名 Cookie の有効期間(秒)。
* redeem は基本 1 回しか走らない(チケットは URL から除去し、リロードは sessionStorage で
* 通す)ため、Cookie 単独で「ページを開いてから 3 点を保存し終えるまで」を賄う必要がある。
* チケット(120s)より十分長い 1 時間にする。失効した場合はアプリ内の CTA から
* 再度チケット発行 → redeem で Cookie を取り直せる(コンプリートは恒久的なので再入場可)。
*/
export const GIF_COOKIE_TTL_SECONDS = 60 * 60
/**
* 署名対象リソース(CloudFront カスタムポリシーの Resource)。
* ホスト名を `*` にして、CloudFront デフォルトドメイン / 独自ドメインのどちらでも
* 同じ署名で通す。パスは保護対象(completion-gift-*)に限定し、他アセットには波及させない。
* CDK 側の behavior パスパターン(/images/completion-gift-*)と対で運用する。
*/
const SIGNED_RESOURCE = 'https://*/images/completion-gift-*'
/** CloudFront が要求する署名 Cookie の固定名(この 3 つを Set-Cookie する)。 */
export const CLOUDFRONT_COOKIE_NAMES = [
'CloudFront-Policy',
'CloudFront-Signature',
'CloudFront-Key-Pair-Id',
] as const
/** Cookie を Path で絞る対象。GIF 実体(/images/ 配下)にだけ送られれば十分。 */
export const GIF_COOKIE_PATH = '/images/'
export interface GifDownloadCookie {
name: string
value: string
}
/**
* CloudFront 署名付き Cookie(3 点)を生成する。
*
* @param keyPairId CloudFront PublicKey の ID(CDK の CfnOutput → GitHub Variable 経由で env 注入)。
* @param privateKey 公開鍵に対応する RSA 秘密鍵 PEM(env では base64 で受け渡すため呼び出し側でデコード済みを渡す)。
* @param nowMs 現在時刻(ミリ秒)。テスト用に差し替え可能。本番では Date.now()。
* @returns Set-Cookie する name/value の配列。
*/
export function issueGifDownloadCookies(
keyPairId: string,
privateKey: string,
nowMs: number = Date.now(),
): GifDownloadCookie[] {
// CloudFront カスタムポリシー: 保護リソースへ TTL 内だけアクセスを許可する。
const expiresEpoch = Math.floor(nowMs / 1000) + GIF_COOKIE_TTL_SECONDS
const policy = JSON.stringify({
Statement: [
{
Resource: SIGNED_RESOURCE,
Condition: { DateLessThan: { 'AWS:EpochTime': expiresEpoch } },
},
],
})
// getSignedCookies は { 'CloudFront-Policy': ..., 'CloudFront-Signature': ...,
// 'CloudFront-Key-Pair-Id': ... } を返す。値は CloudFront 仕様の URL セーフ
// 文字(A-Za-z0-9-_~)のみで構成されるため、Cookie 値としてそのまま使える。
const signed = getSignedCookies({ keyPairId, privateKey, policy })
return CLOUDFRONT_COOKIE_NAMES.map((name) => {
const value = signed[name]
// 署名器が 3 点すべてを返す契約。欠けるのは鍵・ポリシー不正等の異常系なので即エラーにする
// (呼び出し側が catch し、Cookie 未設定のまま全体は落とさずログに残す)。
if (typeof value !== 'string') {
throw new Error(`getSignedCookies did not return a value for ${name}`)
}
return { name, value }
})
}
@aws-sdk/cloudfront-signerを使用して、CloudFrontが検証する3つのCookie
- CloudFront-Policy
- CloudFront-Signature
- CloudFront-Key-Pair-Id
に値を入れていきます。
JSON.stringify()を使用して、ポリシー(アクセス可能なリソースを制御)を設定します。
既存の公開エンドポイントAPI: redeem.post.tsを以下の内容で上書きします。
※過去に書いた記事「【JWT】共通鍵を使って、簡易的な認可システムを実装してみた」で実装していますので、ぜひご合わせてご参照ください。
// ============================================================
// POST /api/public/gif-download/redeem (公開エンドポイント)
// ------------------------------------------------------------
// 外部ブラウザで開いた /gif-download が、URL フラグメントで受け取ったチケット
// (download-ticket.post.ts が発行)の正当性をサーバーに確認するためのエンドポイント。
//
// 外部ブラウザは LINE ログインを持たず、署名シークレットも持てないため、ここは
// ID Token 認証ミドルウェアの対象外(/api/public/ 配下)にしてある。正当性は
// チケットの署名+有効期限の検証で担保する(middleware/auth.ts の除外と対)。
//
// 署名不一致・期限切れ・aud 不一致はすべて 401 にまとめる(失敗理由を返すと oracle に
// なりうるため)。検証が通れば、GIF 実体(/images/completion-gift-*)への到達を絞る
// CloudFront 署名付き Cookie を Set-Cookie で払い出す(gifDownloadCookie.ts)。これにより
// 「ページのチケット検証」だけでなく「オブジェクトへの到達」も配信レイヤーで認可され、
// GIF の URL が漏れても Cookie の無いリクエストは CloudFront が弾く。
// ============================================================
import { verifyGifDownloadTicket } from '../../../utils/gifDownloadTicket'
import {
issueGifDownloadCookies,
GIF_COOKIE_TTL_SECONDS,
GIF_COOKIE_PATH,
} from '../../../utils/gifDownloadCookie'
export default defineEventHandler(async (event) => {
const { gifTicketSecret, gifKeyPairId, gifSigningPrivateKey } = useRuntimeConfig(event)
if (!gifTicketSecret) {
console.error('[gif-ticket] NUXT_GIF_TICKET_SECRET is not configured')
throw createError({
statusCode: 500,
statusMessage: 'Internal Server Error',
message: 'gif ticket misconfigured',
})
}
const body = await readBody<{ ticket?: unknown }>(event)
const ticket = typeof body?.ticket === 'string' ? body.ticket : null
if (!ticket) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: 'missing ticket' })
}
try {
await verifyGifDownloadTicket(ticket, String(gifTicketSecret))
} catch {
// 署名不一致 / 期限切れ / aud 不一致をまとめて 401(理由は返さない)。
throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: 'invalid ticket' })
}
// ----------------------------------------------------------
// CloudFront 署名付き Cookie の発行(GIF 実体への到達を認可)
// ----------------------------------------------------------
// 鍵が未設定の段階(保護 behavior 未配備)では Cookie を発行しない。GIF はまだ
// 公開配信なのでページ側のソフトガードのみで動作し、鍵 + behavior を入れた時点で
// 自動的にハードガードへ切り替わる(デプロイ順序に強いフェイルソフト)。
if (gifKeyPairId && gifSigningPrivateKey) {
// 秘密鍵は PEM の改行が env 経由で壊れないよう base64 で受け渡し、ここでデコードする。
const privateKey = Buffer.from(String(gifSigningPrivateKey), 'base64').toString('utf8')
try {
for (const cookie of issueGifDownloadCookies(String(gifKeyPairId), privateKey)) {
setCookie(event, cookie.name, cookie.value, {
// GIF 実体(/images/ 配下)にだけ送れば十分。API 等には付与しない。
path: GIF_COOKIE_PATH,
httpOnly: true,
secure: true,
// GIF は /gif-download と同一オリジンのサブリソース取得なので lax で送られる。
sameSite: 'lax',
maxAge: GIF_COOKIE_TTL_SECONDS,
// CloudFront の Cookie 値は URL セーフ文字のみ。再エンコードで壊さないよう素通しする。
encode: (value) => value,
})
}
} catch (err) {
// 鍵の形式不正など。Cookie 発行に失敗しても 500 で全体を落とさず、ページ表示自体は
// 通す(GIF 取得時に CloudFront が 403 を返すので情報漏洩はしない)。サーバーログで気付く。
console.error('[gif-download] failed to issue signed cookies', err)
}
}
return { ok: true as const }
})
保存時にbase64化した秘密鍵をデコードします。
setCookie()を使用して、3つのCookieそれぞれの扱い方を設定し、ブラウザに保存します。
以降、有効なCookieを持っているブラウザは、ポリシーに合致するすべてのファイルへアクセスできます。
クッキー保存時の設定まわりに関しては、こちらの記事をぜひご参照ください。
今回は以上になります!
最後までお読みいただきありがとうございました。