API Gateway + Lambda で CloudFront の署名付き URL を発行する構成を AWS CDK で実装する
こんにちは、製造ビジネステクノロジー部の若槻です。
Amazon CloudFront の署名付き URL を使用すると、指定した期間のみ有効な URL でコンテンツを配信できます。これにより、認証されたユーザーのみにコンテンツへのアクセスを制限することが可能となります。
前回のブログで書いた通り、S3 署名付き URL でも同様のことは可能ですが、CloudFront を利用することで WAF や DDoS 対策(AWS Shield)などのセキュリティ機能を利用できたり、エッジロケーションからの配信によりレイテンシーを低減できたりするメリットがあります。
今回は、API Gateway + Lambda で CloudFront の署名付き URL を発行する構成を AWS CDK で実装してみました。
実装
クライアントが署名付き URL でのみ CloudFront 経由で S3 バケット内の画像ファイルにアクセスできるように、次のような構成を AWS CDK で実装します。
- 署名付き URL 取得リクエスト (クライアント → API Gateway)
- Lambda 関数呼び出し (API Gateway → Lambda)
- Lambda 関数が SSM から秘密鍵取得 (Lambda → Parameter Store)
- 署名付き URL 生成 (Lambda → CloudFront)
- 署名付き URL 返却 (Lambda → API Gateway)
- 署名付き URL レスポンス (API Gateway → クライアント)
- 署名付き URL でファイルアクセス (クライアント → CloudFront)
- CloudFront が署名検証 (CloudFront → Public Key)
- CloudFront がオリジンからファイル取得 (CloudFront → S3)
- ファイルレスポンス (CloudFront → クライアント)
キーペアの作成、保存
まずはキーペアを作成して、Systems Manager パラメーターストアに保存します。
通常は RDS ベースの暗号化アルゴリズムが使われることが多いですが、最近 CloudFront で ECDSA(楕円曲線デジタル署名アルゴリズム) がサポートされるアップデートありました。
ECDSA は RSA よりも短い鍵長で同等のセキュリティレベルを提供できるため、せっかくなので今回は ECDSA を使用します。
ECDSA の鍵ペアを生成するコマンドは上記ブログにありますが出典が明記されていなかったので、念の為オフィシャルな情報を探したところ下記が参考になりそうでした。(と言っても最もオフィシャルな情報源は opennssl コマンドの公式サイトとなるのですが)
秘密鍵を生成します。
openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem
秘密鍵から公開鍵を生成します。
openssl ec -in private_key.pem -pubout -out public_key.pem
次に生成したキーペアを Systems Manager パラメーターストアに保存します。
秘密鍵は SecureString タイプで保存します。クライアントから署名付き URL 取得リクエストが来た際に、Lambda 関数から取得して署名付き URL を生成するために使用します。
aws ssm put-parameter --name "/SecureImageDistributor/CloudfrontPrivateKeyPem" --value file://private_key.pem --type SecureString
公開鍵は String タイプで保存します。CDK デプロイ時に CloudFront Public Key を作成するために使用します。
aws ssm put-parameter --name "/SecureImageDistributor/CloudfrontPublicKeyPem" --value file://public_key.pem --type String
Lambda ハンドラー
Node ランタイムの Lambda 関数で CloudFront の署名付き URL を生成するためには、AWS SDK for JavaScript v3 の @aws-sdk/cloudfront-signer
パッケージを利用します。
上記およびパラメーターストアから秘密鍵を取得するためのパッケージを Node.js(TypeScript)プロジェクトにインストールします。
npm i @aws-sdk/cloudfront-signer @aws-lambda-powertools/parameters @aws-sdk/client-ssm
Lambda 関数のハンドラーコードは以下のようになります。
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
import { getParameter } from "@aws-lambda-powertools/parameters/ssm";
const DISTRIBUTION_DOMAIN_NAME = process.env.DISTRIBUTION_DOMAIN_NAME;
if (!DISTRIBUTION_DOMAIN_NAME) {
throw new Error("DISTRIBUTION_DOMAIN_NAME is not set");
}
const CLOUDFRONT_KEY_PAIR_ID = process.env.CLOUDFRONT_KEY_PAIR_ID;
if (!CLOUDFRONT_KEY_PAIR_ID) {
throw new Error("CLOUDFRONT_KEY_PAIR_ID is not set");
}
const PARAM_NAME = "/SecureImageDistributor/CloudfrontPrivateKeyPem";
export const getPresignedUrlHandler = async (): Promise<{ url: string }> => {
const objectKey = "clanyan.jpg"; // オブジェクトキー(検証のため固定)
const expiresInSeconds = 30; // 有効期限(検証のため30秒固定)
// 秘密鍵をパラメーターストアから取得
const privateKey = await getParameter(PARAM_NAME, { decrypt: true });
if (!privateKey) {
throw new Error("Private key not found in SSM parameter");
}
// 署名対象とする URL
const resourceUrl = `https://${DISTRIBUTION_DOMAIN_NAME}/${objectKey}`;
// ISO 8601形式(例: 2025-09-15T14:10:15.801Z)で、現在時刻に有効としたい期間を加算した日時を指定
const dateLessThan = new Date(Date.now() + expiresInSeconds * 1000);
// 署名付き URL の生成
const signedUrl = getSignedUrl({
url: resourceUrl, // 署名対象の URL
keyPairId: CLOUDFRONT_KEY_PAIR_ID, // キーペア ID
privateKey, // PEM 形式の秘密鍵
dateLessThan, // 有効期限
});
return { url: signedUrl };
};
オブジェクトキーは検証のために固定していますが、実際にはクライアントからのリクエストパラメーターなどで指定できるようにすることが多いでしょう。ただし、パストラバーサル攻撃の脆弱性とならないように、オブジェクト名を UUID 化するなどの対策を行うことをお勧めします。
ハンドラーのルーター実装はこちら(本記事の趣旨から外れるため折りたたみ)
Serverless Express を利用して Express アプリケーションを Lambda で動作させ、また API Gateway の Lambda プロキシ統合で呼び出せるようにしています。
import serverlessExpress from "@codegenie/serverless-express";
import cors from "cors";
import express, { Request, Response } from "express";
import { getPresignedUrlHandler } from "./get-presigned-url-handler";
const app = express();
app.use(cors());
app.use(express.json());
app.get("/presigned-url", async (_: Request, res: Response): Promise<void> => {
const data = await getPresignedUrlHandler();
res.status(200).json(data);
});
export const handler = serverlessExpress({ app });
参考:
CDK コード
AWS CDK で上記の Lambda 関数とその他必要なリソースを作成するコードは以下のようになります。
import * as cdk from "aws-cdk-lib";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as cloudfront_origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as logs from "aws-cdk-lib/aws-logs";
import * as lambda_nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3_deployment from "aws-cdk-lib/aws-s3-deployment";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
export class SecureImageDistributorConstruct extends Construct {
constructor(scope: Construct, id: string) {
super(scope, id);
/**
* 画像保管用の S3 バケット
*/
const bucket = new s3.Bucket(this, "Bucket", {
removalPolicy: cdk.RemovalPolicy.DESTROY, // 検証用のため、スタック削除時にバケットも削除する
autoDeleteObjects: true, // 検証用のため、バケット削除時に中身も削除する
});
/**
* パラメーターストアから public_key.pem の取得
*/
const publicKeyPem = ssm.StringParameter.valueForStringParameter(
this,
"/SecureImageDistributor/CloudfrontPublicKeyPem"
);
/**
* CloudFront パブリックキーの作成
*/
const publicKey = new cloudfront.PublicKey(this, "PublicKey", {
encodedKey: publicKeyPem,
});
/**
* CloudFront キーグループの作成
*/
const keyGroup = new cloudfront.KeyGroup(this, "KeyGroup", {
items: [publicKey], // キーグループに含めるパブリックキー
});
/**
* 画像配信用の CloudFront Distribution
*/
const distribution = new cloudfront.Distribution(this, "Distribution", {
defaultBehavior: {
origin:
cloudfront_origins.S3BucketOrigin.withOriginAccessControl(bucket),
trustedKeyGroups: [keyGroup], // CloudFront が署名検証に使用するキーグループ
},
});
/**
* S3 バケットへのコンテンツのデプロイ
*/
new s3_deployment.BucketDeployment(this, "BucketDeploy", {
sources: [s3_deployment.Source.asset("./assets/images")],
destinationBucket: bucket,
distribution,
});
/**
* private-key.pem パラメーターの取得
*/
const privateKeyPemParameter =
ssm.StringParameter.fromSecureStringParameterAttributes(
this,
"PrivateKeyPemParameter",
{ parameterName: "/SecureImageDistributor/CloudfrontPrivateKeyPem" }
);
/**
* Lambda Function
*/
const handler = new lambda_nodejs.NodejsFunction(this, "Handler", {
entry: "src/rest-api-router.ts",
logGroup: new logs.LogGroup(this, "LogGroup"),
environment: {
DISTRIBUTION_DOMAIN_NAME: distribution.domainName, // CloudFront Distribution のドメイン名。署名対象 URL の作成に使用
CLOUDFRONT_KEY_PAIR_ID: publicKey.publicKeyId, // CloudFront キーペア ID として使用
},
});
privateKeyPemParameter.grantRead(handler);
/**
* API Gateway REST API
*/
new apigateway.LambdaRestApi(this, "RestApi", {
handler,
});
}
}
getSignedUrl
関数はローカルでの署名生成処理のみを行うので、Lambda 関数から CloudFront のリソースへのアクセス権限の設定は特に不要なようです。
上記をデプロイすると、API Gateway のエンドポイント URL が出力されるので控えておきます。
動作確認
署名付き URL でアクセス
API Gateway のエンドポイントに対して署名付き URL 発行 API を呼び出します。すると次のようなレスポンスが返却されます。ECDSA だと署名部分がこんなにも短いのですね。
curl https://s7ip98yetl.execute-api.ap-northeast-1.amazonaws.com/prod/presigned-url
{"url":"https://db74fyh4xaxse.cloudfront.net/clanyan.jpg?Expires=1758032116&Key-Pair-Id=K3KBGBZAGRS4YS&Signature=MEUCIQDwSVjP40XiRodiYJW~t43jPQBgIf5EH1Dj8IY08~eh7QIgHEf7JO7-vP2ESdlUzChm2647FAXlO48bVfXtfM22e4Y_"}
取得した署名付き URL にブラウザーでアクセスすると、S3 バケット内の画像(clanyan.jpg)を表示できました!
30秒以上経過後にブラウザの別セッションでアクセスすると、AccessDenied エラーとなり、有効期限がちゃんと動作していることも確認できました。
通常の URL でアクセス
念の為、CloudFront Distribution の通常の URL もブラウザで開いてみると、ちゃんとアクセスできないことが確認できました。
トラブルシュート
パラメーターストアに格納した公開鍵を変更した上で再度デプロイした際に、以下のようなエラーが発生しました。
9:44:01 PM | UPDATE_FAILED | AWS::CloudFront::PublicKey | SecureImageDistributorPublicKey4E5EEAFE
Resource handler returned message: "Invalid request provided: AWS::CloudFront::PublicKey" (RequestToken: 25e81e64-bdfa-0a82-bfab-23bfda8
0a462, HandlerErrorCode: InvalidRequest)
CloudFront の PublicKey リソースは一度作成すると更新できないため、PublicKey を再作成する必要がありました。
参考
以上