Cloudflare R2 に画像をアップロードさせるなら、Worker で PUT 用の署名付き URL を発行し、ブラウザからその URL へ直接 PUT する のが基本の形です。ファイル本体が Worker を通らないので、Worker のリクエストサイズの上限や CPU 時間を気にせずに済み、Worker 側は「誰に・どの種類を・どのキーで」許すかだけを決められます。この記事では、Cloudflare のドキュメントが Workers 向けに案内している aws4fetch で署名を作り、CORS・アップロード後の確認・料金までを、手元で動かして確かめた手順でまとめます(2026 年 10 月時点)。
署名付き URL(presigned URL)は、期限と操作を限定した一時的なアクセス許可を、URL のクエリに埋め込んだもの です。R2 は Amazon S3 互換の API を持っており、S3 と同じ署名方式(AWS Signature Version 4)の URL を受け付けます1 。
使える操作は GET・HEAD・PUT・DELETE。POST のフォームアップロードは非対応1
有効期限は 1 秒〜7 日(604,800 秒)1
宛先は S3 API のドメイン <ACCOUNT_ID>.r2.cloudflarestorage.com。バケットに付けたカスタムドメインでは使えない1
署名に使う秘密鍵(Secret Access Key)は Worker の中だけに置き、ブラウザには「署名済みの URL」だけを渡します。全体の流れは次のとおりです。
ブラウザが Worker に署名付き URL を発行してもらい、R2 へ直接 PUT し、最後に Worker がアップロード結果を確認する 6 段階の流れ 図: 筆者作成
ブラウザが Worker に「この種類・このサイズを上げたい」と伝える
Worker が検査し、キー(保存先のパス)を決めて署名付き URL を返す。この時点では R2 と通信しない
ブラウザが署名付き URL へ直接 PUT する
R2 が署名と CORS を確かめて保存する
ブラウザが Worker に完了を伝える
Worker が R2 バインディングで実物のサイズと種類を確かめる
理由は 3 つあります。
1. Worker が受け取れるリクエストの大きさに上限がある。 Workers のリクエストボディの上限は Cloudflare のプランで決まり、Free と Pro は 100 MB、Business は 200 MB です2 。署名付き URL なら本体は R2 に直接届くので、R2 の単一 PUT の上限(5 GiB)まで受け取れます3 。
Workers が受け取れるリクエストボディの上限(Cloudflare のプラン別、MB) データを表で見る Workers が受け取れるリクエストボディの上限(Cloudflare のプラン別、MB) プラン 上限(MB)(MB) Free 100 Pro 100 Business 200
出典: Cloudflare Workers Limits(Enterprise は self-serve で最大 5 GB。2026-10-05 参照)
2. Worker の CPU 時間と同時実行を使わない。 大きなファイルを Worker で中継すると、その間 Worker が動き続けます。署名を作るだけなら数ミリ秒で終わります。
3. 権限を細かく絞れる。 URL ごとに、キー・メソッド(PUT)・Content-Type・有効期限を固定できます。利用者は「Worker が決めた 1 つのキーに、決めた種類のファイルを、数分以内に 1 回置く」ことしかできません。
逆に、数 MB までの小さなファイルで、アップロード中に中身を加工したい(ウイルススキャン・リサイズなど)場合は、Worker で受け取って R2 バインディングの put() で保存する方が簡単です。
R2 の特徴は、エグレス(外へのデータ転送)が無料 なことです。Workers API・S3 API のどちらで読み出しても転送量の料金はかかりません4 。2026 年 10 月時点の料金は次のとおりです。
項目 Standard Infrequent Access ストレージ 0.015 ドル / GB・月 0.01 ドル / GB・月 Class A 操作(書き込み・一覧など) 100 万回あたり 4.50 ドル 100 万回あたり 9.00 ドル Class B 操作(読み出しなど) 100 万回あたり 0.36 ドル 100 万回あたり 0.90 ドル データ取り出し なし 0.01 ドル / GB エグレス 無料 無料
R2 の操作料金(100 万回あたり、米ドル):Standard と Infrequent Access データを表で見る R2 の操作料金(100 万回あたり、米ドル):Standard と Infrequent Access 操作 Standard(ドル) Infrequent Access(ドル) Class A(書き込みなど) 4.5 9 Class B(読み出しなど) 0.36 0.9
出典: Cloudflare R2 Pricing(2026-10-01 更新、2026-10-05 参照)
Standard には毎月の無料枠があります。ストレージ 10 GB・月、Class A 操作 100 万回、Class B 操作 1,000 万回 です4 。署名付き URL での PUT 1 回は Class A 操作 1 回に当たるので、月 100 万枚までのアップロードは無料枠に収まる計算です(ストレージが 10 GB を超えない場合)。Infrequent Access には 30 日の最低保存期間があり、早く消しても 30 日分の料金がかかります4 。ユーザーの画像のように頻繁に読まれるものは Standard にします。
npx wrangler login
npx wrangler r2 bucket create my-uploads
署名には S3 互換の資格情報(Access Key ID と Secret Access Key)が要ります。Cloudflare のダッシュボードの R2 object storage → API Tokens の Manage から作ります5 。
権限は Object Read & Write を選び、対象を my-uploads バケットだけに絞る。Admin 権限は要らない
作成後に表示される Access Key ID と Secret Access Key を控える。Secret Access Key はこの画面を閉じると二度と表示されない5
資格情報は Worker のシークレットとして登録します。wrangler.jsonc の vars に書くと、設定ファイルと一緒にリポジトリに入ってしまいます6 。
npx wrangler secret put R2_ACCESS_KEY_ID
npx wrangler secret put R2_SECRET_ACCESS_KEY
ローカル開発では、プロジェクト直下の .dev.vars に書きます(.gitignore に入っているか確かめる)。
R2_ACCESS_KEY_ID="..."
R2_SECRET_ACCESS_KEY="..."
ブラウザから別のオリジン(*.r2.cloudflarestorage.com)へ PUT するので、バケットに CORS(Cross-Origin Resource Sharing)の設定が要ります。PUT は「単純なリクエスト」ではないため、ブラウザは先に OPTIONS のプリフライトを送り、R2 が許可を返したときだけ本番の PUT を送ります7 。
ダッシュボードで設定する場合の JSON は、Cloudflare のドキュメントに次の例があります8 7 。
[
{
"AllowedOrigins" : [ "https://example.com" ] ,
"AllowedMethods" : [ "PUT" ] ,
"AllowedHeaders" : [ "Content-Type" ] ,
"ExposeHeaders" : [ "ETag" ] ,
"MaxAgeSeconds" : 3600
}
]
Wrangler で設定する場合は形が違い、rules の配列に書きます7 。headers・exposeHeaders・maxAgeSeconds のキー名は、Wrangler 4.147.0 のソースで確かめたものです。
{
"rules" : [
{
"allowed" : {
"origins" : [ "https://example.com" , "http://localhost:8787" ] ,
"methods" : [ "PUT" ] ,
"headers" : [ "Content-Type" ]
} ,
"exposeHeaders" : [ "ETag" ] ,
"maxAgeSeconds" : 3600
}
]
}
npx wrangler r2 bucket cors set my-uploads --file cors.json
npx wrangler r2 bucket cors list my-uploads
origins には自分のサイトのオリジンだけを書く。* にすると、どのサイトからでも(署名付き URL を手に入れれば)PUT できてしまう
headers に Content-Type を入れ忘れると、プリフライトで弾かれる
Worker からの署名には、Cloudflare のドキュメントが案内している aws4fetch を使います8 。AWS SigV4 の署名を Web 標準の fetch / crypto.subtle だけで作る小さなライブラリで、Workers でそのまま動きます9 。
npm install aws4fetch
wrangler.jsonc には、アップロード後の確認に使う R2 バインディングと、秘密ではない値(アカウント ID・バケット名)を書きます。
{
"$schema" : "node_modules/wrangler/config-schema.json" ,
"name" : "r2-upload" ,
"main" : "src/index.ts" ,
"compatibility_date" : "2026-10-01" ,
"assets" : { "directory" : "./public" } ,
"observability" : { "enabled" : true } ,
"r2_buckets" : [ { "binding" : "BUCKET" , "bucket_name" : "my-uploads" } ] ,
"vars" : {
"R2_ACCOUNT_ID" : "<ACCOUNT_ID>" ,
"R2_BUCKET_NAME" : "my-uploads"
}
}
<ACCOUNT_ID> は自分のアカウント ID に置き換えます。設定を変えたら npx wrangler types で Env の型を作り直します。Worker 本体は次のとおりです。
import { AwsClient } from "aws4fetch" ;
const ALLOWED_TYPES : Record <string , string > = {
"image/png" : "png" ,
"image/jpeg" : "jpg" ,
"image/webp" : "webp" ,
};
const MAX_BYTES = 5 * 1024 * 1024 ;
const EXPIRES_SECONDS = 300 ;
const json = (data : unknown , status = 200 ) => Response .json (data, { status });
async function createUploadUrl (request : Request , env : Env ): Promise <Response > {
const { contentType, size } = await request
.json <{ contentType ?: string ; size ?: number }>()
.catch (() => ({}) as { contentType ?: string ; size ?: number });
const ext = contentType ? ALLOWED_TYPES [contentType] : undefined ;
if (!ext) return json ({ error : "png / jpeg / webp だけアップロードできます" }, 400 );
if (typeof size !== "number" || size <= 0 || size > MAX_BYTES ) {
return json ({ error : `ファイルは ${MAX_BYTES} バイトまでです` }, 400 );
}
const key = `uploads/${crypto.randomUUID()} .${ext} ` ;
const r2 = new AwsClient ({
service : "s3" ,
region : "auto" ,
accessKeyId : env.R2_ACCESS_KEY_ID ,
secretAccessKey : env.R2_SECRET_ACCESS_KEY ,
});
const url = new URL (
`https://${env.R2_ACCOUNT_ID} .r2.cloudflarestorage.com/${env.R2_BUCKET_NAME} /${key} ` ,
);
url.searchParams .set ("X-Amz-Expires" , String (EXPIRES_SECONDS ));
const signed = await r2.sign (
new Request (url, { method : "PUT" , headers : { "Content-Type" : contentType! } }),
{ aws : { signQuery : true , allHeaders : true } },
);
return json ({ key, uploadUrl : signed.url , expiresIn : EXPIRES_SECONDS });
}
async function completeUpload (request : Request , env : Env ): Promise <Response > {
const { key } = await request.json <{ key ?: string }>().catch (() => ({}) as { key ?: string });
if (!key || !key.startsWith ("uploads/" )) return json ({ error : "key が不正です" }, 400 );
const object = await env.BUCKET .head (key);
if (!object ) return json ({ error : "まだアップロードされていません" }, 404 );
if (object .size > MAX_BYTES ) {
await env.BUCKET .delete (key);
return json ({ error : "サイズの上限を超えたため削除しました" }, 400 );
}
return json ({ key, size : object .size , contentType : object .httpMetadata ?.contentType });
}
export default {
async fetch (request, env): Promise <Response > {
const { pathname } = new URL (request.url );
if (request.method === "POST" && pathname === "/api/uploads" ) {
return createUploadUrl (request, env);
}
if (request.method === "POST" && pathname === "/api/uploads/complete" ) {
return completeUpload (request, env);
}
return json ({ error : "not found" }, 404 );
},
} satisfies ExportedHandler <Env >;
要点は次のとおりです。
service: "s3" と region: "auto" を指定する。R2 の S3 API のリージョンは auto です10
有効期限は URL のクエリ X-Amz-Expires(秒)で渡す8
allHeaders: true を付ける (次の節で詳しく説明します)
キーは crypto.randomUUID() でサーバーが決める。利用者にファイル名を決めさせると、他人のファイルを上書きされたり、意図しないパスに置かれたりする
実際のアプリでは、/ api/ uploads の前にログインの確認とレート制限を入れる。誰でも URL を発行できると、無料枠をすぐに使い切られる
Cloudflare の aws4fetch のページには、「Content-Type を指定して署名すると、その種類しかアップロードできなくなる」と書かれ、出力例の X-Amz-SignedHeaders は content-type;host になっています8 。ところが、手元の aws4fetch 1.0.20 でドキュメントどおり { aws: { signQuery: true } } だけで署名したところ、X-Amz-SignedHeaders=host になりました。
aws4fetch のソースには、署名に含めないヘッダーの一覧(UNSIGNABLE_HEADERS)があり、そこに content-type が入っています。この一覧を無視してすべてのヘッダーを署名するオプションが allHeaders です。
署名のオプション X-Amz-SignedHeaders Content-Type の制限 { signQuery: true }host効かない(どの種類でも PUT できる) { signQuery: true, allHeaders: true }content-type;host効く(違う種類なら 403)
allHeaders: true で作った URL が正しいかは、AWS SDK for JavaScript v3 の getSignedUrl で同じ条件(同じ日時・パス形式・クエリ)の URL を作り、署名が一致することで確かめました。R2 の署名付き URL のページは AWS SDK v3 の例を載せています1 。
フロントエンドは、Worker から URL をもらい、fetch で PUT するだけです。署名したときと同じ Content-Type を付ける のが条件です。
<input type ="file" id ="file" accept ="image/png,image/jpeg,image/webp" />
<p id ="status" > </p >
<script type ="module" >
const status = document .getElementById ("status" );
document .getElementById ("file" ).addEventListener ("change" , async (e) => {
const file = e.target .files [0 ];
if (!file) return ;
status.textContent = "URL を発行中…" ;
const res = await fetch ("/api/uploads" , {
method : "POST" ,
headers : { "content-type" : "application/json" },
body : JSON .stringify ({ contentType : file.type , size : file.size }),
});
if (!res.ok ) return (status.textContent = (await res.json ()).error );
const { key, uploadUrl } = await res.json ();
status.textContent = "アップロード中…" ;
const put = await fetch (uploadUrl, {
method : "PUT" ,
headers : { "Content-Type" : file.type },
body : file,
});
if (!put.ok ) return (status.textContent = `アップロード失敗: ${put.status} ` );
const done = await fetch ("/api/uploads/complete" , {
method : "POST" ,
headers : { "content-type" : "application/json" },
body : JSON .stringify ({ key }),
});
status.textContent = JSON .stringify (await done.json ());
});
</script >
このファイルを public/ index.html に置くと、wrangler.jsonc の assets で Worker と同じオリジンから配信されます。/ api/ ... のようにファイルが無いパスだけが Worker に届きます。
署名付き URL では、ファイルの大きさの上限を強制できません 。Worker に申告された size は利用者が自由に書けるからです。そこで、アップロード後に R2 バインディングの head() で実物のサイズと Content-Type を確かめ、上限を超えていれば delete() します(上の completeUpload)。
DB にアップロード記録を残すなら、この完了処理の中で「確認できたキーだけ」を書き込みます。D1 を使う API の作り方は Hono+D1 で REST API を作る で扱っています。完了の連絡が来ないまま残ったオブジェクトは、R2 のライフサイクルルールで uploads/ を一定日数で消すなどして掃除します。
R2 の実バケットを使わずに、どこまで確かめられるかを試しました。資格情報はダミーで、アカウントにはログインしていません。
wrangler dev の Worker に curl で署名付き URL を発行させ、gif を拒否し、aws4fetch と AWS SDK の署名が一致することを確かめたターミナル 筆者が 2026-10-05 に実行した出力。URL の途中と比較スクリプトの出力は 1 行に縮めて表示
wrangler dev を起動して / api/ uploads を呼ぶと、次の JSON が返りました。X-Amz-SignedHeaders=content-type%3Bhost で、Content-Type が署名に入っていることが分かります。
$ curl -s -X POST localhost:8792/api/uploads -H 'content-type: application/json' -d '{"contentType":"image/png","size":48213}'
{"key":"uploads/d5a52a67-f0ec-4746-98b9-028d1022234d.png","uploadUrl":"https://0123456789abcdef0123456789abcdef.r2.cloudflarestorage.com/my-uploads/uploads/d5a52a67-f0ec-4746-98b9-028d1022234d.png?X-Amz-Expires=300&X-Amz-Date=20261005T081644Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=dummy-access-key-id%2F20261005%2Fauto%2Fs3%2Faws4_request&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=c24794f4818754b643ced1a212499d597fcbf30d68e5e23e195ab4b71f4beba7","expiresIn":300}
Workers の Vitest 統合(@cloudflare/ vitest-plugin)でもテストを書きました。R2 バインディングはローカルで再現されるので、env.BUCKET.put() で置いたオブジェクトに対して完了処理を確かめられます。
$ npx vitest run --reporter=verbose
✓ test/upload.spec.ts > POST /api/uploads > PUT 用の署名付き URL を返し、Content-Type が署名に含まれる 86ms
✓ test/upload.spec.ts > POST /api/uploads > 許可していない種類・大きすぎるファイルは 400 18ms
✓ test/upload.spec.ts > POST /api/uploads/complete > R2 にあるオブジェクトのサイズと種類を返す 45ms
✓ test/upload.spec.ts > POST /api/uploads/complete > 上限を超えたオブジェクトは削除して 400 692ms
Test Files 1 passed (1)
Tests 4 passed (4)
確かめられないのは、実際の R2 への PUT(署名の受理・CORS・403 の挙動)です。ここは本番のバケットと資格情報で、許可した種類・違う種類・期限切れの 3 通りを試してから公開します。
この構成を Claude Code に作らせるなら、署名の落とし穴とやらないことを先に伝えます。プロジェクトの作り方と CLAUDE.md の準備は Claude Code で Cloudflare Workers を作る全手順 を参照してください。
R2 への画像アップロードを、署名付き URL で実装してください。
- POST /api/uploads: contentType(png/jpeg/webp)と size(5MB まで)を検査し、
uploads/<uuid>.<拡張子> の PUT 用 URL を aws4fetch で発行する。有効期限 300 秒
- Content-Type を署名に含めるため、sign() に allHeaders: true を付ける
- POST /api/uploads/complete: R2 バインディングの head() でサイズと種類を確かめ、
上限を超えていたら delete() する
- 資格情報は .dev.vars と wrangler secret。vars やコードに書かない
- @cloudflare/vitest-plugin でテストを書き、npx vitest run と tsc を通す
- wrangler deploy・r2 bucket cors set・secret put は実行せず、コマンドを提示するだけ
ブラウザが送る Content-Type が、署名した値と違う(image/ jpg と image/ jpeg など)
URL を組み立て直した、またはクエリの順番やエンコードを変えた
有効期限が切れている
Access Key の権限がそのバケットに無い
バケットの CORS で、オリジン・メソッド(PUT)・ヘッダー(Content-Type)のどれかが足りません。npx wrangler r2 bucket cors list で設定を確かめます。ローカルで試すなら http:/ / localhost:8787 もオリジンに入れます。
署名付き URL は <ACCOUNT_ID>.r2.cloudflarestorage.com でしか使えません1 。カスタムドメインは公開読み出し(GET)用に使い分けます。
手元で AWS SDK v3 の getSignedUrl を既定のまま使うと、URL に x-amz-checksum-crc32 と x-amz-sdk-checksum-algorithm が付きました。S3Client に requestChecksumCalculation: "WHEN_REQUIRED" を指定すると付かなくなります。R2 の S3 互換 API はチェックサムの対応が一部に限られるので10 、SDK を使う場合はこの設定を確かめてください。
署名付き URL は、持っている人なら誰でも使える「持参人トークン」です1 。ログに残さない、期限は短く(数分)する、キーは 1 回限りにする、を守ります。AI が書いたコードの見直し方は AI 生成コードのセキュリティ も参考にしてください。
R2 の署名付き URL は、Worker が発行し、ブラウザが R2 へ直接 PUT する。本体は Worker を通らない
aws4fetch で署名するときは allHeaders: true を付けないと、Content-Type が署名に入らない
バケットの CORS で、自分のオリジン・PUT・Content-Type を許可する
サイズの上限は署名では強制できない。アップロード後に R2 バインディングの head() で確かめる
2026 年 10 月時点の R2 は、エグレス無料、Standard の無料枠はストレージ 10 GB・Class A 100 万回・Class B 1,000 万回
1 秒から 7 日(604,800 秒)まで指定できます1 。アップロード用は、ファイルを選んでから PUT するまでの数分あれば足りるので、5 分程度の短い期限にします。
Workers なら aws4fetch が軽くて扱いやすく、Cloudflare のドキュメントにも例があります8 。ただし Content-Type を署名に含めるには allHeaders: true が必要です。Node.js のサーバーで署名するなら、R2 のドキュメントが例を載せている AWS SDK v3 の getSignedUrl でも構いません1 。
できません。Worker で申告サイズを検査したうえで、アップロード後に R2 バインディングの head() で実物のサイズを確かめ、超えていれば削除します。
転送量(エグレス)の料金はかかりません4 。読み出しの操作(Class B)には料金がかかりますが、Standard なら毎月 1,000 万回までは無料枠です。
数 MB までの小さなファイルで、保存前に中身を加工・検査したいなら Worker で受け取る方が簡単です。大きなファイルや大量のアップロードは、Worker のリクエストボディの上限(Free・Pro は 100 MB)や CPU 時間を避けられる署名付き URL が向いています2 。