API Gateway + Lambda で S3 署名付き URL (ダウンロード用) を発行する構成を AWS CDK で実装する

API Gateway + Lambda で S3 署名付き URL (ダウンロード用) を発行する構成を AWS CDK で実装する

2025.09.15

こんにちは、製造ビジネステクノロジー部の若槻です。

Amazon S3 の署名付き URL (Presigned URL) を利用すると、認証情報を持たないユーザーに対して一時的にオブジェクトへのアクセス権を付与することができます。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html

例えば画像や動画など容量の大きいファイルのやり取りをクライアント側アプリと AWS 上のサーバー側アプリとの間で行わせたい場合に、API でデータを直接やり取りするのではなく、署名付き URL を発行して S3 から直接ダウンロード/アップロードさせることで、API の負荷を軽減したり、転送コストを削減したりすることができます。

今回は、API Gateway + Lambda で S3 署名付き URL (ダウンロード用) を発行する構成を AWS CDK で実装してみました。

実装

クライアントが署名付き URL でのみ S3 バケット内の画像にアクセスできるように、次のような構成を AWS CDK で実装します。

  1. 署名付き URL 取得リクエスト (クライアント → API Gateway)
  2. Lambda 関数呼び出し (API Gateway → Lambda)
  3. 署名付き URL 生成 (Lambda → S3 Bucket)
  4. 署名付き URL 返却 (Lambda → API Gateway)
  5. 署名付き URL レスポンス (API Gateway → クライアント)
  6. 署名付き URL でファイルアクセス (クライアント → S3 Bucket)

Lambda ハンドラー

オブジェクトダウンロード(GetObject)用の署名付き URL を生成するためには、AWS SDK for JavaScript v3 の @aws-sdk/client-s3@aws-sdk/s3-request-presigner パッケージを利用します。

https://aws.amazon.com/jp/blogs/developer/generate-presigned-url-modular-aws-sdk-javascript/

それぞれのパッケージを Node.js(TypeScript)プロジェクトにインストールします。

			
			npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

		

Lambda 関数のハンドラーコードは以下のようになります。

src/get-presigned-url-handler.ts
			
			import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const BUCKET_NAME = process.env.BUCKET_NAME;
if (!BUCKET_NAME) {
  throw new Error("Environment variable BUCKET_NAME is not set");
}

const s3Client = new S3Client();

export const getPresignedUrlHandler = async (): Promise<{ url: string }> => {
  // オブジェクトキー(検証のため固定)
  const objectKey = "clanyan.jpg";

  // 有効期限(検証のため30秒固定)
  const expiresIn = 30;

  // 署名付き URL 生成
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: objectKey,
  });

  const presignedUrl = await getSignedUrl(s3Client, command, {
    /**
     * 署名付き URL の有効期限(秒)
     * デフォルトは900秒(15分)、最大で604800秒(7日間)まで設定可能
     * @see https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html#PresignedUrl-Expiration
     */
    expiresIn,
  });

  return { url: presignedUrl };
};

		

クライアントに許可させたいコマンド(ここでは GetObjectCommand)を指定して getSignedUrl 関数を呼び出すことで署名付き URL を生成します。

オブジェクトキーは検証のために固定していますが、実際にはクライアントからのリクエストパラメーターなどで指定できるようにすることが多いでしょう。ただし、パストラバーサル攻撃の脆弱性とならないように、オブジェクト名を UUID 化するなどの対策を行うことをお勧めします。

有効期限はデフォルトで900秒(15分)ですが、ここでは検証のために30秒に設定しています。こちらもクライアントから指定可能とすることもできますが、ユースケースに応じて上限値を設け、バリデーションで制限すると良いでしょう。

ハンドラーのルーター実装はこちら(本記事の趣旨から外れるため折りたたみ)

Serverless Express を利用して Express アプリケーションを Lambda で動作させ、また API Gateway の Lambda プロキシ統合で呼び出せるようにしています。

src/rest-api-router.ts
			
			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 });

		

参考:

https://dev.classmethod.jp/articles/serverless-express-express-v5-lambda-proxy-integration-aws-cdk/

CDK コード

AWS CDK で上記の Lambda 関数と API Gateway、S3 バケットを作成するコードは以下のようになります。

lib/constructs/secure-image-distributor/index.ts
			
			import * as cdk from "aws-cdk-lib";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as iam from "aws-cdk-lib/aws-iam";
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 { 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, // 検証用のため、バケット削除時に中身も削除する
    });

    /**
     * S3 バケットへのコンテンツのデプロイ
     *
     * MEMO: 検証のため、ローカルの画像をデプロイする
     */
    new s3_deployment.BucketDeployment(this, "BucketDeploy", {
      sources: [s3_deployment.Source.asset("./assets/images")],
      destinationBucket: bucket,
    });

    /**
     * Lambda Function
     */
    const handler = new lambda_nodejs.NodejsFunction(this, "Handler", {
      entry: "src/rest-api-router.ts",
      logGroup: new logs.LogGroup(this, "LogGroup"),
      environment: {
        BUCKET_NAME: bucket.bucketName,
      },
    });
    bucket.grantRead(handler); // Lambda Function に S3 バケットの読み取り権限を付与

    /**
     * API Gateway REST API
     */
    new apigateway.LambdaRestApi(this, "RestApi", {
      handler,
    });
  }
}

		

オブジェクトダウンロード用の署名付き URL を発行する Lambda 関数に S3 バケットの読み取り権限を付与する必要があるので、bucket.grantRead(handler) を実行しています。

上記をデプロイすると、API Gateway のエンドポイント URL が出力されるので控えておきます。

動作確認

署名付き URL でアクセス

API Gateway のエンドポイントに対して署名付き URL 発行 API を呼び出します。すると次のようなレスポンスが返却されます。

			
			$ curl https://qqnlssjcx1.execute-api.ap-northeast-1.amazonaws.com/prod/presigned-url
{"url":"https://main-secureimagedistributorbucket8d59e35b-pyo28rgddwm9.s3.ap-northeast-1.amazonaws.com/clanyan.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAUL6WVPY2MPO44LZE%2F20250914%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20250914T144909Z&X-Amz-Expires=30&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEOf%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkcwRQIhAMiKG7WNviF3WUtaxqLUhSq6lBT%2FXyTylaveJBuU20TyAiAPB5VbP%2FPo0lfscz%2BnTsawV32RNa33l%2FOJ3o3GDg%2FQnCrIAwhgEAMaDDMwMDU2MTAzODkwMCIMyVahFwyt2cgi52GaKqUD0Fy3Kzl6Xl7BqUWW4MSE60SY4Lfzy8i69pduIK%2FQ3Xod5%2Fe7BDN6uNMrSS0KAVLwS0VOmT5npb2kTlQj0A98ppmz1OlyEbdj9EoeOYdUReBrI2DFkx3ZNQquZ0sEjR%2Be6guefWJIDaumwy1gizIdtnQrzGaNjB%2Fcf83ap7AXxgdztD3CeEub8b5m3idbbvnNkhdlS57jsqoENp26aDiR0xpO8sYSL8FqUJeJ%2FJyV4%2BZHLrzfYJhXzgH%2F4EuKA1Tk%2FK9tLcPLFPLUeYpnn%2BhGKZBOQ0qWpv5y8Gj7okh6zt%2FDnEDfrgCu5LOrsDGMU0iDbgbegUh3pMG1WtKK7i3i1Bo%2Fn3GpGfZbqPksHqlItUWu18BfsH2YumLcvFDrSl2Ji7KUQQErcEkWjBLNsiY6ttOa7Tq8ujoTbR1be4s%2FHcjSU3xwHqShir%2FvXJBohVszx%2B3diuZE8gPGfi6Az1tJCkTjfMZ2UZfZ7S7xSRzPejhmqiT0mH2%2FayHc%2FzH%2F81QhHjlKhL%2FYVYFbm5yT9LE1DJg46sxoVcsBjB%2BnHjkQ3lSqPAYsjjDkq5vGBjqeAUFx0pC8klaENrtiJ%2F2a6TGRt9k2khODYgPw4%2FYwu8at%2BMUScDePQNhvfBHvN5Gx%2BZroM%2F55MnyDmwQqAepYf3rqaT7mgSueAgq1FRvGcpEgOXol1Gs%2BrgkYT3Niij1yIygikp5Bm2UXsPu1kU%2BFNOdujFENquCrMrzRXuMIKCUzr7wT7y%2BxhjJGxoGSG5c90nx0tJqdtiJnfQzC0G5d&X-Amz-Signature=8ce30df513d21c61c86f84e90d917f75bb6d12599f3a0a8511e1d0a2ae0b3ea6&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject"}

		

取得できた URL を見やすく改行したものが以下です。

			
			https://main-secureimagedistributorbucket8d59e35b-pyo28rgddwm9.s3.ap-northeast-1.amazonaws.com/clanyan.jpg?
  X-Amz-Algorithm=AWS4-HMAC-SHA256&
  X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&
  X-Amz-Credential=ASIAUL6WVPY2MPO44LZE%2F20250914%2Fap-northeast-1%2Fs3%2Faws4_request&
  X-Amz-Date=20250914T144909Z&
  X-Amz-Expires=30&
  X-Amz-Security-Token=IQoJb3JpZ2luX2VjEOf%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkcwRQIhAMiKG7WNviF3WUtaxqLUhSq6lBT%2FXyTylaveJBuU20TyAiAPB5VbP%2FPo0lfscz%2BnTsawV32RNa33l%2FOJ3o3GDg%2FQnCrIAwhgEAMaDDMwMDU2MTAzODkwMCIMyVahFwyt2cgi52GaKqUD0Fy3Kzl6Xl7BqUWW4MSE60SY4Lfzy8i69pduIK%2FQ3Xod5%2Fe7BDN6uNMrSS0KAVLwS0VOmT5npb2kTlQj0A98ppmz1OlyEbdj9EoeOYdUReBrI2DFkx3ZNQquZ0sEjR%2Be6guefWJIDaumwy1gizIdtnQrzGaNjB%2Fcf83ap7AXxgdztD3CeEub8b5m3idbbvnNkhdlS57jsqoENp26aDiR0xpO8sYSL8FqUJeJ%2FJyV4%2BZHLrzfYJhXzgH%2F4EuKA1Tk%2FK9tLcPLFPLUeYpnn%2BhGKZBOQ0qWpv5y8Gj7okh6zt%2FDnEDfrgCu5LOrsDGMU0iDbgbegUh3pMG1WtKK7i3i1Bo%2Fn3GpGfZbqPksHqlItUWu18BfsH2YumLcvFDrSl2Ji7KUQQErcEkWjBLNsiY6ttOa7Tq8ujoTbR1be4s%2FHcjSU3xwHqShir%2FvXJBohVszx%2B3diuZE8gPGfi6Az1tJCkTjfMZ2UZfZ7S7xSRzPejhmqiT0mH2%2FayHc%2FzH%2F81QhHjlKhL%2FYVYFbm5yT9LE1DJg46sxoVcsBjB%2BnHjkQ3lSqPAYsjjDkq5vGBjqeAUFx0pC8klaENrtiJ%2F2a6TGRt9k2khODYgPw4%2FYwu8at%2BMUScDePQNhvfBHvN5Gx%2BZroM%2F55MnyDmwQqAepYf3rqaT7mgSueAgq1FRvGcpEgOXol1Gs%2BrgkYT3Niij1yIygikp5Bm2UXsPu1kU%2BFNOdujFENquCrMrzRXuMIKCUzr7wT7y%2BxhjJGxoGSG5c90nx0tJqdtiJnfQzC0G5d&
  X-Amz-Signature=8ce30df513d21c61c86f84e90d917f75bb6d12599f3a0a8511e1d0a2ae0b3ea6&
  X-Amz-SignedHeaders=host&
  x-amz-checksum-mode=ENABLED&
  x-id=GetObject

		

この署名付き URL には、AWS Signature Version 4 による認証情報が含まれており、30秒間(X-Amz-Expires=30)の有効期限が設定されていることが分かります。この辺は私もちゃんと説明できる自信が無いので、詳しくは以下のドキュメントを参照してください。

https://docs.aws.amazon.com/prescriptive-guidance/latest/presigned-url-best-practices/overview.html

取得した署名付き URL にブラウザーでアクセスすると、S3 バケット内の画像(clanyan.jpg)を表示できました!

30秒以上経過後にブラウザの別セッションでアクセスすると、AccessDenied エラーとなり、有効期限がちゃんと動作していることも確認できました。

ちなみに最後にアクセスした時と同じセッションでアクセスするとブラウザ側のキャッシュで期限経過後も画像が表示される場合があるので検証の際には注意しましょう。

通常の URL でアクセス

念の為、S3 バケットの通常の URL もブラウザで開いてみると、ちゃんとアクセスできないことが確認できました。

https://main-secureimagedistributorbucket8d59e35b-pyo28rgddwm9.s3.ap-northeast-1.amazonaws.com/clanyan.jpg

補足

上限値より大きい有効期限が指定された場合の挙動

前述の通り、署名付き URL の有効期限は最大で7日間(604800秒)まで設定可能です。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html#PresignedUrl-Expiration

それでは次のように7日間を超える値(10日間)を指定して署名付き URL の発行を試すとどうなるでしょうか。

			
			const presignedUrl = await getSignedUrl(s3Client, command, {
  /**
   * 署名付き URL の有効期限(秒)
   * デフォルトは900秒(15分)、最大で604800秒(7日間)まで設定可能
   * @see https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html#PresignedUrl-Expiration
   */
  expiresIn: 10 * 24 * 60 * 60, // 10日間(上限値を超えた場合はエラーとなる)
});

		

すると Lambda 上で getSignedUrl() の実行が次のようなエラーとなりました。

Error: Signature version 4 presigned URLs must have an expiration date less than one week in the future

このように、上限値を超えた場合は SDK 側でエラーとなります。ただし繰り返しになりますが、有効期限をリクエストパラメーターなどでクライアントから指定可能とする場合は、バリデーションで上限値を設けることをお勧めします。

CloudFront Distribution の署名付き URL 発行も可能

今回は S3 バケットの署名付き URL を発行しましたが、S3 バケットをオリジンとした CloudFront Distribution の署名付き URL を発行することも可能です。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html

CloudFront を利用することで WAF や DDoS 対策(AWS Shield)などのセキュリティ機能を利用できたり、エッジロケーションからの配信によりレイテンシーを低減(ただし Cache Policy でクエリパラメータをキャッシュキーから無視するなどの対応は必要)できたりするメリットがあります。

こちらは次回以降に試してみたいと思います。

バケットポリシーによる更なるセキュリティ強化

今回は利用しませんでしたが、S3 バケットポリシーで各種条件を指定することで、S3 バケットへのアクセス時のセキュリティを更に強化することが可能です。

https://docs.aws.amazon.com/ja_jp/service-authorization/latest/reference/list_amazons3.html#amazons3-s3_signatureAge

バケットポリシー設定例
			
			bucket.addToResourcePolicy(
  new iam.PolicyStatement({
    effect: iam.Effect.DENY,
    principals: [new iam.AnyPrincipal()],
    actions: ["s3:GetObject"],
    resources: [bucket.arnForObjects("*")],
    conditions: {
      // 複数の条件を組み合わせ
      NumericGreaterThan: {
        "s3:signatureAge": 3600000, // 署名の経過時間が1時間(3600秒)を超える場合に拒否(ただし1時間を超える署名付きURLの発行自体は可能)
      },
      StringNotLike: {
        "s3:prefix": ["uploads/*", "public/*"], // 特定パスのみ許可
      },
    },
  })
);

		

必要に応じてご検討ください。

アップロード用の署名付き URL 発行では更なるセキュリティ考慮が必要

今回はダウンロード用の署名付き URL 発行を実装しましたが、アップロード用の署名付き URL 発行も同様に実装可能です。そして、アップロード用の場合はファイル整合性検証や Content-Type の改ざん防止など、更なるセキュリティの考慮が必要となります。

  • ファイル整合性検証:URL 発行リクエスト時にクライアントにアップロード対象のファイルのチェックサムを送信させ、実際のアップロード時にチェックする
  • Content-Type の改ざん防止:画像ファイルに限定したいユースケースにおいて、攻撃者が text/html や application/json など不正な挙動を引き起こすことを目的としたファイルをアップロードすることを防ぐ

いずれも @aws-sdk/s3-request-presigner での SDK 呼び出し時に対応可能です。詳しくは下記を参照してください。

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-s3-request-presigner/

参考

https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v3/developer-guide/migrate-s3.html#s3-presigned-url

https://aws.amazon.com/blogs/compute/securing-amazon-s3-presigned-urls-for-serverless-applications/

https://aws.amazon.com/blogs/security/how-to-securely-transfer-files-with-presigned-urls/

以上

この記事をシェアする

FacebookHatena blogX

関連記事