S3 Presigned URLのセキュリティ設計|AWS SDK v3・有効期限・Content-Type・SSE
- S3 Presigned URLは短命・用途固定・パラメータ署名バインドが安全設計の三本柱だ。
- AWS SDK v3では
PutObjectCommand/GetObjectCommandに Content-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(GetObject・PutObject など)を直接呼び出せる。
典型的なユースケースは次の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つだ。
- セッション認証 — 未ログインユーザーにはURLを発行しない
- CSRFトークン検証 — Cookieとヘッダーの一致を確認(CSRF対策の詳細)
- Content-Typeの許可リスト — クライアント申告を盲信せずサーバー側で検証
- キーのプレフィックス —
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;
}
PutObjectCommand に ContentType: "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)の固定
| コマンドプロパティ | 署名への影響 |
|---|---|
ContentType | PUT時の Content-Type ヘッダーが固定される |
ResponseContentType | GET時のレスポンス 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:GenerateDataKey と kms: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トークンを検証する。
ユーザーがログインし、HttpOnly + Secure + SameSite=Lax のセッションCookieが発行される([セキュアCookie設計](/blog/secure-cookie-設定-セキュリティ/))
サーバーがCSRFトークンをCookieまたはHTML metaに埋め込み、状態変更系APIで検証する([CSRF対策](/blog/csrf-対策-トークン-実装/))
認証済みクライアントが POST /api/uploads/presign を呼び、contentType等を送信する
APIがユーザーIDに基づきオブジェクトキーを決定し、PutObjectCommandで署名したURLを返す
ブラウザがPresigned URLへ直接PUTし、完了後にAPIへobjectKeyを登録する
登録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)というハイブリッドがよく使われる。
まとめ——チェックリスト
本番リリース前に次を確認する。
- 署名発行はサーバーのみ — IAMキーはクライアントに渡さない
- expiresInは短く — PUT 5〜15分、GET 1〜5分を基準
- Content-Type・SSEを署名に固定 — 改ざんで403になることをテスト済み
- キーはユーザー/テナントでスコープ — UUIDで推測困難に
- 発行APIに認証+CSRF — CSRF対策とセキュアCookieを適用
- PUT後はHeadObject検証 — サイズ・MIME・所有者を確認してからDB登録
- Block Public Access有効 — バケットポリシーでTLS必須
- 監査ログと異常検知 — 発行回数・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の中身を確認する方法|改ざんが検知される仕組み |