S3 Presigned URLのセキュリティ設計|AWS SDK v3・有効期限・Content-Type・SSE

AWS S3 セキュリティ Node.js バックエンド
結論
  • S3 Presigned URLは短命・用途固定・パラメータ署名バインドが安全設計の三本柱だ。
  • AWS SDK v3では PutObjectCommand / GetObjectCommandContent-Type・SSE・オブジェクトキー を署名時に固定し、expiresIn はPUT 5〜15分・GET 1〜5分を目安にする。
  • 発行APIはセッション認証とCSRFトークン検証を必須とし、CookieはHttpOnly + Secure + SameSiteで保護する。
  • URL自体は秘密情報ではないが漏洩=一時的な権限移譲と捉え、キー設計・監査ログ・Block Public Accessと多層防御すること。

Presigned URLとは——仕組みと脅威モデル

Amazon S3のPresigned URL(署名付きURL)は、IAMユーザーやロールの認証情報を使って計算された一時的な署名をクエリパラメータに付与したURLだ。URLを知っているクライアントは、署名の有効期限内かつ署名に含まれたパラメータの範囲で、S3 API(GetObjectPutObject など)を直接呼び出せる。

典型的なユースケースは次の2つだ。

  • ブラウザからの直接アップロード — アプリサーバーを経由せず大容量ファイルをS3へPUTし、帯域とレイテンシを削減する
  • 期限付きダウンロードリンク — 会員限定資料・請求書PDFなどを、ログインセッションとは別に短時間だけ取得可能にする

いずれも「バックエンドがS3認証情報を持ち、フロントには一時URLだけ渡す」という権限委譲パターンになる。ここで設計を誤ると、次のような事故が起きる。

脅威成立条件影響
URL漏洩による不正ダウンロード長い有効期限・推測可能なキー機密ファイルの第三者取得
任意ファイルアップロードPUT署名にContent-Type未固定・キー制御なしマルウェア配布・ストレージ汚染
ストレージコスト攻撃認証なき発行API・巨大ファイル許可請求額の急増
CSRF経由の不正発行発行APIにCSRF対策なし被害者名義でのオブジェクト作成
署名パラメータ改ざんクライアントが余計なヘッダーを付与可能な設計意図しない暗号化・メタデータ

Presigned URLは認可の代替ではなく、認可済み操作を一時的に委譲する仕組みだ。発行APIでのユーザー認証、オブジェクトキーの所有者検証、CSRF防御が欠けると、URLの安全性は担保できない。

AWS SDK v3(Node.js)の基本構成

2026年時点の新規実装では AWS SDK for JavaScript v3(モジュラー構成)を使う。v2の AWS.S3.getSignedUrl ではなく、@aws-sdk/client-s3@aws-sdk/s3-request-presigner の組み合わせが標準だ。

import { S3Client } from "@aws-sdk/client-s3";

const s3Client = new S3Client({
  region: process.env.AWS_REGION ?? "ap-northeast-1",
  // ローカル開発のみ: endpoint / credentials を明示
  // 本番はECS/EKS/LambdaのIAMロールに任せる
});

SDK v3の利点は次のとおりだ。

  • コマンドパターンPutObjectCommand など操作ごとに型安全な入力を定義できる
  • 署名対象の明示 — コマンドに含めたパラメータだけが署名にバインドされる
  • Tree-shaking — 使うクライアントだけバンドルでき、Lambdaのコールドスタートに有利
認証情報はサーバーにだけ置く

Presigned URLを発行するコードは必ずサーバーサイド(API Route、BFF、Lambda)で動かす。IAMアクセスキーをフロントエンドの環境変数(NEXT_PUBLIC_ など)に載せる実装は絶対に避ける。発行に使うロールは s3:PutObject / s3:GetObject を対象バケット・プレフィックスに限定した最小権限にする。

PUT Presigned URL——アップロードの安全な発行

ユーザーがプロフィール画像をアップロードする例を考える。安全な設計では、サーバーがオブジェクトキー・Content-Type・最大サイズ・SSEを決め、クライアントはその条件でのみPUTできる。

発行APIの実装(Express + AWS SDK v3)

import express, { Request, Response } from "express";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";

const app = express();
app.use(express.json());

const s3 = new S3Client({ region: "ap-northeast-1" });
const BUCKET = process.env.S3_UPLOAD_BUCKET ?? "my-app-uploads-prod";
const UPLOAD_TTL_SECONDS = 300;

type SessionUser = { id: string };

function getSessionUser(req: Request): SessionUser | null {
  const session = req.session as { userId?: string } | undefined;
  if (!session?.userId) {
    return null;
  }
  return { id: session.userId };
}

app.post("/api/uploads/presign", async (req: Request, res: Response) => {
  const user = getSessionUser(req);
  if (!user) {
    res.status(401).json({ error: "unauthorized" });
    return;
  }

  const csrfHeader = req.header("x-csrf-token");
  const csrfCookie = req.cookies?.csrf_token as string | undefined;
  if (!csrfHeader || !csrfCookie || csrfHeader !== csrfCookie) {
    res.status(403).json({ error: "csrf_invalid" });
    return;
  }

  const body = req.body as { contentType?: string; fileName?: string };
  const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
  if (!body.contentType || !allowedTypes.has(body.contentType)) {
    res.status(400).json({ error: "invalid_content_type" });
    return;
  }

  const extension = body.contentType === "image/jpeg"
    ? "jpg"
    : body.contentType === "image/png"
      ? "png"
      : "webp";

  const objectKey = `users/${user.id}/avatars/${randomUUID()}.${extension}`;

  const command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: objectKey,
    ContentType: body.contentType,
    ServerSideEncryption: "AES256",
    Metadata: {
      "uploaded-by": user.id,
    },
  });

  const uploadUrl = await getSignedUrl(s3, command, {
    expiresIn: UPLOAD_TTL_SECONDS,
  });

  res.json({
    uploadUrl,
    objectKey,
    expiresIn: UPLOAD_TTL_SECONDS,
    requiredHeaders: {
      "Content-Type": body.contentType,
    },
  });
});

export default app;

この例で押さえるポイントは4つだ。

  1. セッション認証 — 未ログインユーザーにはURLを発行しない
  2. CSRFトークン検証 — Cookieとヘッダーの一致を確認(CSRF対策の詳細
  3. Content-Typeの許可リスト — クライアント申告を盲信せずサーバー側で検証
  4. キーのプレフィックスusers/{userId}/ で他ユーザーの領域と分離

フロントエンドからのPUT(fetch)

async function uploadAvatar(file: File): Promise<string> {
  const csrfToken = readCsrfTokenFromMeta();
  const presignResponse = await fetch("/api/uploads/presign", {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken,
    },
    body: JSON.stringify({
      contentType: file.type,
      fileName: file.name,
    }),
  });

  if (!presignResponse.ok) {
    throw new Error(`presign failed: ${presignResponse.status}`);
  }

  const presign = (await presignResponse.json()) as {
    uploadUrl: string;
    objectKey: string;
    requiredHeaders: { "Content-Type": string };
  };

  const putResponse = await fetch(presign.uploadUrl, {
    method: "PUT",
    headers: {
      "Content-Type": presign.requiredHeaders["Content-Type"],
    },
    body: file,
  });

  if (!putResponse.ok) {
    throw new Error(`s3 put failed: ${putResponse.status}`);
  }

  return presign.objectKey;
}

function readCsrfTokenFromMeta(): string {
  const meta = document.querySelector('meta[name="csrf-token"]');
  if (!meta) {
    throw new Error("csrf meta tag not found");
  }
  const content = meta.getAttribute("content");
  if (!content) {
    throw new Error("csrf meta content empty");
  }
  return content;
}
Content-Typeは署名と実リクエストが一致必須

PutObjectCommandContentType: "image/png" を含めて署名した場合、クライアントのPUTリクエストも同じ Content-Type ヘッダーを付けなければ403になる。逆に署名時にContent-Typeを省略すると、クライアントは任意のMIMEでアップロードでき、後段の配信でXSSリスクが生じる。アップロード時は必ず固定すること。

GET Presigned URL——ダウンロードの安全な発行

請求書PDFを会員だけが短時間ダウンロードする例だ。GET署名では ResponseContentDisposition でダウンロード時のファイル名を固定し、ブラウザ内インライン表示(特にHTML/SVG)を避ける。

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });
const BUCKET = "my-app-documents-prod";
const DOWNLOAD_TTL_SECONDS = 120;

type InvoiceRecord = {
  userId: string;
  s3Key: string;
  fileName: string;
};

async function createInvoiceDownloadUrl(
  invoice: InvoiceRecord,
  requestUserId: string,
): Promise<string> {
  if (invoice.userId !== requestUserId) {
    throw new Error("forbidden");
  }

  const safeFileName = invoice.fileName.replace(/[^\w.\-]/g, "_");
  const command = new GetObjectCommand({
    Bucket: BUCKET,
    Key: invoice.s3Key,
    ResponseContentDisposition: `attachment; filename="${safeFileName}"`,
    ResponseContentType: "application/pdf",
  });

  const downloadUrl = await getSignedUrl(s3, command, {
    expiresIn: DOWNLOAD_TTL_SECONDS,
  });

  return downloadUrl;
}

ResponseContentDisposition: attachment により、ブラウザは同一オリジン上でないS3レスポンスでもダウンロードとして保存しやすくなり、PDFビューア内のスクリプト実行リスクを下げられる(PDF自体の脆弱性は別問題だが、HTML偽装よりマシだ)。

有効期限(expiresIn)の設計指針

getSignedUrl の第3引数 expiresIn秒単位だ。デフォルトは900秒(15分)だが、用途に応じて明示的に短くするのが望ましい。

操作 推奨expiresIn・理由
PUT(ユーザー操作) 300〜900秒。フォーム送信〜ファイル選択完了までをカバーしつつ、漏洩時の被害時間を最小化
PUT(バックグラウンド) 60〜300秒。巨大ファイルはマルチパート+Upload Part用の個別署名を検討
GET(ワンタイム) 60〜180秒。メール内リンク・チャット共有向け。再取得はAPIから再発行
GET(社内ダッシュボード) 300〜3600秒。UXとのトレードオフ。1時間超はCloudFront Signed URLや認可プロキシへ
DELETE 60秒以下。削除は即時性が重要で、長いURLは危険

有効期限の上限とIAMロールセッション

Presigned URLの最大有効期限は、署名に使った認証情報の種類で変わる。

  • IAMユーザーの長期キー — 理論上は長期だが、運用上はローテーション前提で短命URLのみ発行
  • IAMロールの一時クレデンシャル — ロールの最大セッション時間(通常1〜12時間)が上限
  • Lambda実行ロール — 実行時間とセッション有効期限の小さい方が実質上限
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });

export async function presignWithExplicitExpiry(
  bucket: string,
  key: string,
  contentType: string,
  expiresInSeconds: number,
): Promise<string> {
  const maxAllowed = 900;
  const safeExpiry = Math.min(Math.max(expiresInSeconds, 60), maxAllowed);

  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: contentType,
    ServerSideEncryption: "AES256",
  });

  return getSignedUrl(s3, command, { expiresIn: safeExpiry });
}
ワンタイム消費パターン

高機密ファイルでは、ダウンロードURLをDBに保存し、初回GET成功後にレコードを無効化する「ワンタイムトークン」パターンが有効だ。S3側の署名は短く、消費管理はアプリケーション層で行う。Presigned URL単体には「1回限り」の概念はない。

Content-TypeとChecksum——署名バインドの実務

S3署名v4では、リクエストに含まれる特定ヘッダーとクエリパラメータが署名対象になる。SDK v3のコマンドプロパティはこれらにマッピングされる。

Content-Type(MIME)の固定

コマンドプロパティ署名への影響
ContentTypePUT時の Content-Type ヘッダーが固定される
ResponseContentTypeGET時のレスポンス Content-Type が上書きされる
ResponseContentDispositionインライン/添付とファイル名が固定される

HTMLファイルのアップロードを防ぐ例だ。

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });

const BLOCKED_TYPES = new Set([
  "text/html",
  "application/xhtml+xml",
  "image/svg+xml",
  "text/javascript",
  "application/javascript",
]);

export async function presignSafeUpload(
  bucket: string,
  key: string,
  contentType: string,
): Promise<string> {
  if (BLOCKED_TYPES.has(contentType.toLowerCase())) {
    throw new Error(`blocked content type: ${contentType}`);
  }

  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: contentType,
    ServerSideEncryption: "AES256",
  });

  return getSignedUrl(s3, command, { expiresIn: 300 });
}

ChecksumAlgorithm(整合性検証)

AWS SDK v3では ChecksumAlgorithm: "CRC32" などを指定し、クライアントに x-amz-checksum-crc32 ヘッダーの付与を要求できる。破損検出に加え、パラメータ改ざん検知の補助になる。

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });

export async function presignWithChecksum(
  bucket: string,
  key: string,
  contentType: string,
): Promise<{ url: string; checksumHeader: string }> {
  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: contentType,
    ChecksumAlgorithm: "CRC32",
    ServerSideEncryption: "AES256",
  });

  const url = await getSignedUrl(s3, command, { expiresIn: 300 });

  return {
    url,
    checksumHeader: "x-amz-checksum-crc32",
  };
}

クライアントはファイルバイナリからCRC32を計算し、Base64エンコードした値をヘッダーに載せる必要がある。フロント実装の複雑さとのトレードオフを評価すること。

SSE(サーバーサイド暗号化)の指定

S3に保存するデータは、転送中(TLS)だけでなく保存時にも暗号化する。Presigned PUTでは、暗号化方式を署名に含めることで、クライアントが平文PUTや意図しないキーでの暗号化を行えないようにする。

方式 Presigned URLでの指定・特徴
SSE-S3(AES256) ServerSideEncryption: 'AES256'。S3管理キー。設定が簡単でコスト低。コンプライアンスでキー管理要件が緩い場合向け
SSE-KMS ServerSideEncryption: 'aws:kms' + SSEKMSKeyId。KMS CMKで鍵管理・監査が可能。kms:Decrypt/kms:GenerateDataKey のIAMが別途必要
DSSE-KMS ダブル暗号化。規制産業向け。署名パラメータとKMSポリシーの設計がより厳密になる

SSE-S3の署名例

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });

export async function presignSseS3(
  bucket: string,
  key: string,
  contentType: string,
): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: contentType,
    ServerSideEncryption: "AES256",
  });

  return getSignedUrl(s3, command, { expiresIn: 300 });
}

SSE-KMSの署名例

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });
const KMS_KEY_ARN = "arn:aws:kms:ap-northeast-1:123456789012:key/abcd1234-5678-90ab-cdef-EXAMPLE11111";

export async function presignSseKms(
  bucket: string,
  key: string,
  contentType: string,
): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: contentType,
    ServerSideEncryption: "aws:kms",
    SSEKMSKeyId: KMS_KEY_ARN,
  });

  return getSignedUrl(s3, command, { expiresIn: 300 });
}

発行ロールのIAMポリシーには、S3に加えてKMSの kms:GenerateDataKeykms:Decrypt(ダウンロード時)が必要だ。KMSキーポリシー側でもS3サービスと発行ロールを許可すること。

暗号化パラメータの欠落は署名エラーになる

署名時に ServerSideEncryption: "aws:kms" を含めたのに、クライアントPUTで x-amz-server-side-encryption ヘッダーを付け忘れると403になる。発行APIのレスポンスに requiredHeaders を返し、フロントの実装を固定すること。

オブジェクトキー設計とIAM最小権限

Presigned URLの安全性は、キーが推測困難かつ所有者でスコープされているかに大きく依存する。

キー命名の定石

users/{userId}/uploads/{uuid}.{ext}
tenants/{tenantId}/exports/{yyyy}/{mm}/{reportId}.pdf
tmp/{sessionId}/{uuid}   # 短命一時ファイル。Lifecycleで24h削除

避けるべきパターンは次のとおりだ。

  • uploads/{fileName} — ファイル名の衝突と推測が容易
  • public/{sequentialId}.pdf — 連番は総当たり可能
  • ユーザー入力をそのままキーに — パストラバーサル風の ../ 混入

IAMポリシー例(発行ロール)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PresignPutOwnPrefix",
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::my-app-uploads-prod/users/*"
    },
    {
      "Sid": "PresignGetOwnPrefix",
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::my-app-documents-prod/users/*"
    }
  ]
}

アプリケーション層で users/{authenticatedUserId}/ を強制し、IAMでもワイルドカードを可能な限り狭める。二重チェックが理想だ。

CORS設定——ブラウザ直接アクセス時の注意

ブラウザからS3へ直接PUT/GETする場合、バケットのCORS設定が必要だ。過度に緩いCORSは不要なオリジンからの署名付きリクエストを許してしまう(URL自体は秘密だが、XSSで奪われた場合に有効)。

<CORSConfiguration>
  <CORSRule>
    <AllowedOrigin>https://app.example.com</AllowedOrigin>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedHeader>Content-Type</AllowedHeader>
    <AllowedHeader>x-amz-server-side-encryption</AllowedHeader>
    <AllowedHeader>x-amz-checksum-crc32</AllowedHeader>
    <ExposeHeader>ETag</ExposeHeader>
    <MaxAgeSeconds>3600</MaxAgeSeconds>
  </CORSRule>
</CORSConfiguration>

AllowedOrigin* を使わず、ステージング・本番でオリジンを分ける。ローカル開発は http://localhost:3000 を別ルールで追加する。

認証・CSRF・Cookieとの統合

Presigned URLアーキテクチャでは、発行APIがセキュリティの要になる。フロントはCookieセッションで認証し、発行エンドポイントはCSRFトークンを検証する。

1

ユーザーがログインし、HttpOnly + Secure + SameSite=Lax のセッションCookieが発行される([セキュアCookie設計](/blog/secure-cookie-設定-セキュリティ/))

2

サーバーがCSRFトークンをCookieまたはHTML metaに埋め込み、状態変更系APIで検証する([CSRF対策](/blog/csrf-対策-トークン-実装/))

3

認証済みクライアントが POST /api/uploads/presign を呼び、contentType等を送信する

4

APIがユーザーIDに基づきオブジェクトキーを決定し、PutObjectCommandで署名したURLを返す

5

ブラウザがPresigned URLへ直接PUTし、完了後にAPIへobjectKeyを登録する

6

登録APIでも所有者チェックとCSRF検証を行い、DBにメタデータを保存する

セッションCookieの属性については セキュアなCookie設定ガイド を参照。特に SameSite=Lax によりクロスサイトPOSTでの発行が抑えられ、CSRFトークンと併用することで多層防御になる。

アップロード完了の確認API

PUT成功をクライアントの自己申告だけで信用しない。S3 HeadObject で実在・サイズ・Content-Typeを検証してからDB登録する。

import {
  HeadObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "ap-northeast-1" });
const BUCKET = "my-app-uploads-prod";
const MAX_BYTES = 5 * 1024 * 1024;

type RegisterBody = {
  objectKey: string;
  expectedContentType: string;
};

export async function verifyAndRegisterUpload(
  userId: string,
  body: RegisterBody,
): Promise<{ ok: true; etag: string }> {
  if (!body.objectKey.startsWith(`users/${userId}/`)) {
    throw new Error("forbidden_key");
  }

  const head = await s3.send(
    new HeadObjectCommand({
      Bucket: BUCKET,
      Key: body.objectKey,
    }),
  );

  const size = head.ContentLength ?? 0;
  if (size <= 0 || size > MAX_BYTES) {
    throw new Error("invalid_size");
  }

  const actualType = head.ContentType ?? "";
  if (actualType !== body.expectedContentType) {
    throw new Error("content_type_mismatch");
  }

  const etag = head.ETag ?? "";
  return { ok: true, etag };
}

マルチパートアップロードと大容量ファイル

5GBを超えるファイルや、途中再開が必要な場合は Multipart Upload を使う。Presigned URLは各 UploadPart ごとに発行する。

import {
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });

export async function startMultipartPresign(
  bucket: string,
  key: string,
  contentType: string,
): Promise<{ uploadId: string }> {
  const create = await s3.send(
    new CreateMultipartUploadCommand({
      Bucket: bucket,
      Key: key,
      ContentType: contentType,
      ServerSideEncryption: "AES256",
    }),
  );

  if (!create.UploadId) {
    throw new Error("upload_id_missing");
  }

  return { uploadId: create.UploadId };
}

export async function presignUploadPart(
  bucket: string,
  key: string,
  uploadId: string,
  partNumber: number,
): Promise<string> {
  const command = new UploadPartCommand({
    Bucket: bucket,
    Key: key,
    UploadId: uploadId,
    PartNumber: partNumber,
  });

  return getSignedUrl(s3, command, { expiresIn: 3600 });
}

export async function completeMultipart(
  bucket: string,
  key: string,
  uploadId: string,
  parts: Array<{ ETag: string; PartNumber: number }>,
): Promise<void> {
  await s3.send(
    new CompleteMultipartUploadCommand({
      Bucket: bucket,
      Key: key,
      UploadId: uploadId,
      MultipartUpload: { Parts: parts },
    }),
  );
}

パートごとの署名は有効期限が切れても、UploadIdが有効なら再発行できる。完了前の放置パートはLifecycleルールで AbortIncompleteMultipartUpload を設定し、ストレージリークを防ぐ。

バケットポリシーとBlock Public Access

Presigned URLはプライベートバケットでも機能する。むしろバケットをパブリックにしないことが前提だ。

推奨設定のチェックリストだ。

  • Block Public Access — 4項目すべて有効
  • バケットポリシーで aws:SecureTransport: true — HTTP拒否
  • オブジェクト所有者強制(Bucket owner enforced) — ACL混乱の防止
  • バージョニング+MFA Delete — 誤削除・ランサム対策(要件に応じて)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-app-uploads-prod",
        "arn:aws:s3:::my-app-uploads-prod/*"
      ],
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    }
  ]
}

監査・ログ・アラート

Presigned URLはCloudTrailに PutObject / GetObject として記録される。誰がURLを発行したかはアプリログで追跡する必要がある。

構造化ログの例

import pino from "pino";

const logger = pino({ name: "presign-service" });

type PresignAuditEvent = {
  action: "presign_put" | "presign_get";
  userId: string;
  bucket: string;
  objectKey: string;
  expiresIn: number;
  contentType: string;
  ip: string;
};

export function logPresignIssued(event: PresignAuditEvent): void {
  logger.info(
    {
      audit: true,
      action: event.action,
      userId: event.userId,
      bucket: event.bucket,
      objectKey: event.objectKey,
      expiresIn: event.expiresIn,
      contentType: event.contentType,
      ip: event.ip,
    },
    "presigned_url_issued",
  );
}

アラート例は次のとおりだ。

  • 単一ユーザーが1分間に100件以上のPresign発行
  • 通常と異なる Content-Type での大量PUT
  • 署名期限切れ403の急増(スクレイピング・総当たりの兆候)

よくある実装ミスと対策

ミス 対策
フロントにIAMキーを埋め込む サーバーサイドのみで署名。Lambda/ECSのロールを使用
expiresInを86400秒以上に設定 用途別に短く。長期は別仕組み(CloudFront、認可プロキシ)
ユーザー入力をそのままS3キーに UUID付与・プレフィックス強制・拡張子はサーバー決定
PUT後の登録APIなし HeadObjectで検証してからDB登録
発行APIにCSRF対策なし トークン検証+SameSite Cookie([CSRFガイド](/blog/csrf-対策-トークン-実装/))
CORSで AllowedOrigin: * 本番オリジンのみ許可
SSEパラメータを署名に含めない PutObjectCommandでServerSideEncryptionを必ず指定

署名改ざんテスト(curl)

ステージングで次を確認する。

# 正常PUT
curl -X PUT -H "Content-Type: image/png" --data-binary "@sample.png" "PRESIGNED_URL"

# Content-Type改ざん(403になるべき)
curl -X PUT -H "Content-Type: text/html" --data-binary "@evil.html" "PRESIGNED_URL"

# 期限切れ後(403になるべき)
sleep 301 && curl -X PUT -H "Content-Type: image/png" --data-binary "@sample.png" "PRESIGNED_URL"

CloudFrontとの使い分け

Presigned S3 URLはS3エンドポイントを直接露出する。CDNキャッシュ・カスタムドメイン・WAF適用が必要な場合は CloudFront Signed URL/Cookie との併用を検討する。

方式向くケース
S3 Presigned URLシンプルな直接アップロード/ダウンロード、実装コスト最小
CloudFront Signed URL配信のキャッシュ、カスタムドメイン、地理制限、WAF
アプリプロキシ(BFF経由ストリーム)きめ細かい認可、レート制限、ウイルススキャン挿入

アップロードはS3 Presigned、ダウンロード配信はCloudFrontオリジンアクセス制御(OAC)というハイブリッドがよく使われる。

まとめ——チェックリスト

本番リリース前に次を確認する。

  1. 署名発行はサーバーのみ — IAMキーはクライアントに渡さない
  2. expiresInは短く — PUT 5〜15分、GET 1〜5分を基準
  3. Content-Type・SSEを署名に固定 — 改ざんで403になることをテスト済み
  4. キーはユーザー/テナントでスコープ — UUIDで推測困難に
  5. 発行APIに認証+CSRFCSRF対策セキュアCookieを適用
  6. PUT後はHeadObject検証 — サイズ・MIME・所有者を確認してからDB登録
  7. Block Public Access有効 — バケットポリシーでTLS必須
  8. 監査ログと異常検知 — 発行回数・403急増のアラート

Presigned URLは強力な仕組みだが、「URLを知っていれば期限内は操作できる」という性質を忘れてはならない。短命・固定パラメータ・発行時の厳格な認可——この3点を軸に設計すれば、ブラウザ直接アップロードのメリットを安全に享受できる。

関連記事

ブラウザセッションの保護は セキュアなCookie設定ガイド、発行APIのCSRF対策は CSRFトークン実装ガイド をあわせて読むと、アップロードフロー全体の防御が揃う。

関連記事

この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
XSS攻撃と防御の完全ガイドXSS攻撃と防御の完全ガイド|反射型・蓄積型・DOM型の対策
Double Submit Cookie完全実装Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
Content Security Policy (CSP) 設定ガイドContent Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策
CSP Report-Only 段階導入ガイドCSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する
セキュリティヘッダー一覧と設定ガイドセキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy
CORSエラーの対処法CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する
CORSプリフライトリクエストCORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答
ローカル開発でCORSエラーが出る原因と3つの回避策ローカル開発でCORSエラーが出る原因と3つの回避策
OAuth2 PKCE SPA実装ガイドOAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン
Passkeys・WebAuthn実装の基礎Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server
JWTのデバッグ方法JWTのデバッグ方法|トークンの中身を安全に確認する
JWTの中身を確認する方法JWTの中身を確認する方法|改ざんが検知される仕組み