ブラウザからS3へ画像を直接送ろうとしたら、ConsoleにCORSエラー。Networkには403も出ている。こうなると、CORSの設定が悪いのか、署名付きURLが間違っているのか、判断しづらい。
最初に見るのは、どの送信先の、どのHTTPメソッドが失敗したか。S3への事前確認であるOPTIONSと、画像本体を送るPUTを分けると、調べる順番を決めやすくなる。
この記事は、2026年11月1日時点のAmazon S3汎用バケット、AWS Signature Version 4、単発の署名付きPUTを前提にしている。ブラウザ側はFetch API、コマンド例はUbuntu・WSLのBashとcurlを使う。署名付きPOST、分割するマルチパートアップロード、S3互換ストレージでは、送信形式や必要な設定が異なる。
全体の構成は「画像をアプリサーバーに通さない、S3直接アップロードの設計」で説明している。ここでは、送信が失敗したときの調査に絞って進める。
まず、OPTIONSとPUTのどちらが失敗したかを見る
別のオリジンにあるS3へPUTする場合、ブラウザは通常、その前にOPTIONSで送信条件を確認する。これがプリフライトリクエスト。「このWebサイトから、このメソッドとヘッダーで送ってよいか」という確認だ。許可を確認できなければ、画像本体のPUTは送られない。確認結果がキャッシュされていると、毎回OPTIONSが出るわけではない。MDN:CORSとプリフライト
flowchart LR
accTitle: S3直接アップロードのエラーを調べる順番
accDescr: 失敗した送信先を確認し、S3へのOPTIONS、PUT、保存後の処理の順に切り分ける。
A["送信先とメソッドを確認"] --> B["OPTIONS:CORSの許可"]
B --> C["PUT:署名・期限・権限"]
C --> D["完了通知:アプリの処理"]
図は確認する順番を示している。失敗した段階ごとの調査先は、次の表で確認できる。
| Networkで分かったこと | 最初に調べる場所 |
|---|---|
| 署名付きURLを発行するアプリAPIが403 | アプリ側のログイン状態や操作権限 |
S3へのOPTIONSが403で、PUTが送られていない |
S3のCORS設定と、実際のリクエスト条件 |
S3へのPUTが403 |
応答のエラーコード、署名、期限、AWSの権限 |
S3へのPUTは200だが、JavaScriptから結果を読めない |
応答のCORSヘッダー、公開するレスポンスヘッダー |
S3へのPUTは200だが、アプリの完了通知が失敗 |
完了通知APIと、サーバー側の保存確認 |
ConsoleにCORSエラーが出たことだけで、原因をCORS設定に決めない。 S3が返したエラー応答をブラウザ側で読み取れず、元の403の詳細が見えない場合もある。まずNetworkで通信の段階を確認する。MDN:CORSエラーの調査
1. Network画面で、送信先・メソッド・ヘッダーを記録する
ブラウザの開発者ツールでNetworkを開き、画像をもう一度送る。OPTIONSが見つからなければ、Fetch/XHRだけに絞るフィルターを外して、全リクエストも確認する。
確認したいのは、次の項目。
| 項目 | 確認する内容 |
|---|---|
| Request URL | アプリAPIかS3か。想定したバケット・リージョン・キーか |
| Request Method | OPTIONS、PUT、POSTなど、実際に送ったメソッド |
| Status Code | 403、200、リダイレクトなど |
Origin |
ページを開いているサイトのオリジン |
Access-Control-Request-Method |
事前確認で申告した本番のメソッド |
Access-Control-Request-Headers |
事前確認で申告したヘッダー名の一覧 |
| Response | 読める場合はXMLのCodeとMessage |
例えば、画面をhttp://localhost:5173で開いているなら、許可するオリジンもそれに合わせる。httpとhttps、ホスト名、ポートが違えば別のオリジンになる。https://app.example.com/uploadのようなページのパスは含めない。MDN:オリジンの定義
署名付きURLは、持っている人がその操作を行える情報でもある。スクリーンショットや問い合わせにURLを載せる場合は、?以降のクエリ全体を伏せる。HARや「Copy as cURL」にも完全なURLが入ることがある。AWS:署名付きURLの保護
2. OPTIONSが403なら、S3のCORS設定を照合する
S3は、CORSが未設定の場合や、オリジン・メソッド・ヘッダーが許可ルールに合わない場合に、プリフライトへ403を返す。エラーメッセージにはCORS is not enabled for this bucketやThis CORS request is not allowedなどがある。AWS:CORSのトラブルシューティング
S3コンソールに設定するJSONの例
S3コンソールで対象バケットの「アクセス許可」を開き、「Cross-origin resource sharing(CORS)」を編集する。次は、https://app.example.comから、Content-Typeヘッダーを付けたPUTを許可するルール例。既存の設定がある場合は、他の用途のルールも確認してから追加・調整する。AWS:CORSの設定方法
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["ETag", "x-amz-request-id", "x-amz-id-2"],
"MaxAgeSeconds": 300
}
]
これはS3コンソール用の配列形式。AWS CLIのput-bucket-corsなどではCORSRulesで包む形式になるため、設定先の入力形式にも注意する。AWS CLI:put-bucket-cors
AllowedOrigins:アップロード画面を開くオリジン。開発環境を使うなら、必要なオリジンを追加する。AllowedMethods:実際に行う操作。この例はPUT。事前確認のOPTIONSは追加しない。 S3で指定できる値はGET・PUT・POST・DELETE・HEAD。AllowedHeaders:プリフライトが申告するリクエストヘッダー名を許可する。SDKなどがチェックサムや暗号化用のヘッダーを送る場合は、それらも照合する。ExposeHeaders:JavaScriptから読ませるレスポンスヘッダー。この例ではETagと調査用のリクエストIDを公開する。MaxAgeSeconds:事前確認の結果をキャッシュできる秒数。300はこの例の設定値。
ヘッダーの名前を許可するCORS設定と、ヘッダーの値も関係する署名の検証は別の確認になる。AWS:CORS設定の各要素
「送っているつもりのヘッダー」と、実際のヘッダーを比べる
例えば、コードではContent-Typeしか指定していなくても、共通のHTTPクライアントがAuthorizationやX-CSRF-Tokenを付けていることがある。NetworkのAccess-Control-Request-Headersを基準に調べる。
アプリ用の認証ヘッダーがS3へのリクエストにも付いているなら、その送信が必要かを先に見直す。クエリで認証する署名付きURLに、アプリのBearerトークンを足す必要はない。
また、ブラウザからS3へ直接送る構成では、アップロードのCORS応答を返すのはS3。アプリサーバーのCORS設定を変えても、S3のプリフライトへの応答は変わらない。
3. curlでOPTIONSを送り、CORSの応答を見る
ブラウザのエラー表示だけでは分かりづらい場合は、curlで事前確認と同じ条件を送る。次のURLは例なので、実際のバケット・リージョン・オブジェクトキーに置き換える。ここでは署名付きURLのクエリを付けず、同じS3オブジェクトのエンドポイントを使う。
s3_object_url='https://amzn-s3-demo-bucket.s3.ap-northeast-1.amazonaws.com/uploads/cors-check.png'
curl --include --request OPTIONS \
--header 'Origin: https://app.example.com' \
--header 'Access-Control-Request-Method: PUT' \
--header 'Access-Control-Request-Headers: content-type' \
"$s3_object_url"
OriginとAccess-Control-Request-Headersは、Networkで見た値に合わせる。例えばヘッダー名が複数あるなら、content-type,x-amz-checksum-sha256のようにカンマで区切る。
応答は、ステータスだけでなく、次の対応を確認する。
| 応答ヘッダー | 確認すること |
|---|---|
Access-Control-Allow-Origin |
送ったオリジンが許可されているか |
Access-Control-Allow-Methods |
PUTが含まれるか |
Access-Control-Allow-Headers |
申告したヘッダーがすべて許可されているか |
S3のOPTIONSはバケットのCORSルールを評価する操作。ここが成功しても、PutObjectの権限や署名が正しいことまでは確認できていない。AWS:OPTIONS object
curlはブラウザのCORS制限を実施しないため、応答をそのまま確認できる。curlで通信できたことと、ブラウザがその応答を利用できることは分けて判断する。
4. PUTが403なら、curlでエラー本文を確認する
次は、署名付きURLへのPUTをブラウザの外で試す。実際にファイルを保存する操作なので、検証用のバケットと新しいキーで発行したURLを使う。同じキーへの送信は、条件によって既存ファイルを置き換える。
ここでは、Content-Type: image/pngで送る前提のURLと、手元のsample.pngを使う。必要な署名対象ヘッダーがほかにもある場合は、それも発行時の条件どおりに付ける。
read -r -s -p '検証用の署名付きPUT URLを入力: ' upload_url
printf '\n'
curl --include \
--request PUT \
--header 'Content-Type: image/png' \
--upload-file './sample.png' \
"$upload_url"
unset upload_url
URLを直接コマンドに書かずに入力すると、シェルの履歴へURLを書き込まずに試せる。変数は"$upload_url"のように引用符で囲み、値が単語分割やファイル名展開の対象にならないようにする。URLを直接書く場合も、&などがシェルの記号として解釈されないように引用符で囲む。AWS:署名付きURLによるアップロードとcurlの例
この例はOriginを付けずに送り、署名や権限の調査に使う。失敗したら、返ってきたXMLのCodeとMessageを確認する。
| コード・メッセージの例 | 次に確認すること |
|---|---|
SignatureDoesNotMatch |
HTTPメソッド、URL、署名対象ヘッダーの値 |
AccessDenied |
対象キーへの権限、ポリシー条件、明示的な拒否 |
Request has expiredなどの期限切れメッセージ |
URLを発行した日時と有効期間 |
ExpiredToken(HTTP 400) |
発行に使った一時認証情報の期限 |
RequestTimeTooSkewed |
署名を作るサーバーの時刻 |
403という番号だけでは原因を区別できない。また、署名付きアップロードでは、ExpiredTokenのように400になる関連エラーもある。HTTPステータスと合わせて、XMLのコードとメッセージを読む。AWSのエラー一覧でもそれぞれの意味を確認できる。
curlが成功してブラウザが失敗するなら、次は両者のオリジン、ヘッダー、送信データ、使用したURLの期限を比べる。送信元IPを制限している構成では、curlを実行した場所とブラウザの通信元の違いも確認する。
5. SignatureDoesNotMatchは、発行時と送信時の条件を比べる
署名は「このURLならどんな送り方でもよい」という許可ではない。まず、次の違いを探す。
PUT用URLをGET・POST・HEADで使っていないか
PUT用URLをブラウザのアドレスバーで開くと、通常はGETになる。curl --headもHEADになる。アップロード用URLの確認には、そのURLを発行したときと同じメソッドを使う。
PUTの本文には、画像のFileやBlobをそのまま渡す。FormDataはフォームの境界情報などを含む別の送信形式。署名付きPOSTを使う場合は、POST用ポリシーと指定されたフォームフィールドを使う。AWS:署名付きPOSTの認証
PUTにFormDataを渡すと、Content-Typeの不一致で拒否される場合がある。条件によっては送信自体が成功し、画像の代わりにフォームを包んだデータが保存されるため、200になった場合も本文の形式を確認したい。
署名したContent-Typeなどの値が一致しているか
例えばContent-Typeをimage/pngとして署名したのに、送信時はapplication/octet-streamやmultipart/form-dataになっていれば、署名と一致しない。ファイルの拡張子だけで判断せず、実際のリクエストヘッダーを見る。
URLのX-Amz-SignedHeadersには、署名対象のヘッダー名が入る。content-typeが含まれる場合は、その値も発行時と合わせる。hostはブラウザが設定するため、JavaScriptから手動で指定しない。チェックサムや暗号化の条件も、発行側が要求した形に合わせる。AWS:SigV4のクエリ認証と署名対象ヘッダー
URLのホスト・パス・クエリを変更していないか
バケットやリージョンが違っていたら、発行側の設定を直してURLを作り直す。発行済みURLのホスト名だけを書き換える方法では、署名条件も変わってしまう。
URLを再エンコードしたり、途中で切ったり、&が文字列として&に変わったものを使ったりしていないかも確認する。一時認証情報を使う場合は、X-Amz-Security-Tokenを含むクエリも必要になる。
署名不一致では、送信メソッド・ヘッダー・バケットとキー・リージョン・時刻を確認するのがAWSの案内する手順でもある。AWS re:Post:署名不一致の確認項目
6. AccessDeniedなら、発行元の権限・期限・条件を確認する
URLを発行できても、アップロード権限があるとは限らない
署名付きURLを利用するときは、発行に使ったIAMユーザーやロールの権限で操作が評価される。URLの文字列を生成できたことだけでは、S3への保存が許可される保証にならない。AWS:署名付きURLを作成できるユーザーと権限
確認したいのは、発行元が対象オブジェクトへs3:PutObjectできるか。例えばuploads/以下へ保存するなら、対象はarn:aws:s3:::amzn-s3-demo-bucket/uploads/*のようなオブジェクトのARNになる。バケット自体のARNだけを指定していないかも確認する。AWS:S3 APIに必要な権限
必要なAllowがあっても、バケットポリシーなどの明示的なDenyや、適用されるSCP・アクセス許可の境界などで拒否されることがある。エラーにポリシーの種類や拒否理由が含まれていれば、そこからたどる。AWS:403 Access Deniedの調査
URLの期限と、一時認証情報の期限を確認する
URLに設定した期限が残っていても、発行に使ったロールの一時認証情報が先に期限切れになると、そのURLは使えなくなる。ExpiredTokenの場合は、発行側で有効な認証情報を取得してからURLを作り直す。
また、バケットポリシーのs3:signatureAgeや送信元ネットワークの条件によって、URLの設定期限内でも拒否されることがある。サーバーだけが使うVPCエンドポイント経由に制限しているバケットなら、一般のブラウザからの直接送信経路と両立するかを確認する。AWS:署名付きURLの期限と利用条件
SSE-KMSでは、KMS側の権限も確認する
保存時の暗号化にSSE-KMSを使っている場合、S3への書き込み権限に加えて、KMSキーの利用許可も関係する。単発のPutObjectによる暗号化には、対象キーへのkms:GenerateDataKeyが必要。KMSキーポリシーなども含めて、発行元が利用できるか確認する。
マルチパートアップロードではkms:Decryptも必要になるなど、条件が変わる。単発PUTの確認結果を、そのまま別のアップロード方式へ当てはめない。AWS:SSE-KMSの必要権限
バケットポリシーが特定の暗号化ヘッダーを必須にしている場合は、その条件も確認する。ヘッダーを変更するなら、署名時の条件とCORSで許可する名前の両方に反映する。
7. PUTが200なら、応答の読み取りと後続処理を調べる
PutObjectが成功したときは200が返り、通常はJSONの応答本文はない。送信後にresponse.json()を呼んで失敗しているだけ、という場合もある。AWS:PutObjectのレスポンス
アプリがURLと必要なヘッダーを返す構成なら、ブラウザ側の送信処理は次のように書ける。uploadはurlとheadersを持ち、この例のheadersは{"Content-Type":"image/png"}、fileは選択したPNGのFileを想定する。
async function uploadToS3(file, upload) {
const response = await fetch(upload.url, {
method: 'PUT',
headers: upload.headers,
body: file,
credentials: 'omit',
});
if (!response.ok) {
throw new Error(`S3への送信に失敗: HTTP ${response.status}`);
}
return {
etag: response.headers.get('ETag'),
};
}
Fetch APIは、読み取れるHTTPエラー応答を受け取っても、それだけではPromiseを拒否しない。response.okで成否を確認する。CORSやネットワークで読み取り自体が失敗した場合は、呼び出し元で例外を受けて、Network画面へ戻って調べる。MDN:Fetch APIの応答処理
ETagだけ読めない場合
NetworkにはETagがあるのにresponse.headers.get('ETag')がnullなら、CORSのExposeHeadersを確認する。AllowedHeadersはリクエスト側、ExposeHeadersはレスポンスをJavaScriptへ公開する側の設定になる。AWS:レスポンスヘッダーが見つからない場合
完了通知やHeadObjectが失敗している場合
S3への保存後にアプリの完了通知APIが403なら、そのAPIの認証・操作権限・アップロードIDなどを調べる。
アプリサーバーが保存確認にHeadObjectを使う場合は、確認側にs3:GetObjectの権限が必要。さらに、指定したキーが存在しない場合でも、確認側にs3:ListBucketがないと404ではなく403になる。そのため、403だけで「ファイルは存在するが読めない」とは判断できない。発行時・アップロード時・確認時のキーも照合する。AWS:HeadObjectの権限とエラー
エラーを直すときも、バケットの公開は必要ない
CORSは、ブラウザから別オリジンへのアクセスを扱う仕組み。S3へ保存する権限は、署名やAWSのアクセス制御で決まる。署名付きURLで非公開バケットへアップロードする構成なら、その経路のためにバケットを一般公開する必要はない。AWS:S3のCORSとアクセス制御
mode: 'no-cors'もPUTの解決策にはならない。このモードで指定できるメソッドはGET・HEAD・POSTに限られ、応答もJavaScriptから読めない。CORSに合う応答をS3から返せるように調整する。MDN:Requestのmode
次に同じ症状が出たときは、送信先 → OPTIONS → PUTのエラー本文 → 署名・期限・権限 → 保存後の処理の順で確認する。変更した条件ごとに結果を記録しておけば、CORSと403をひとまとめにせず、どこまで成功しているかを追える。
この記事の設定・コード・コマンドは公式資料に基づく説明例。実際のAWSバケットへのアップロード検証は行っていないため、利用する署名発行処理やバケットポリシーに合わせて確認してほしい。