記事一覧に戻る
Web技術系

S3直接アップロードでCORSエラー・403が出たときの調べ方

S3への直接アップロードがCORSエラーや403で失敗するときの確認手順。OPTIONSとPUTの切り分け、CORS設定、curlでの再現、署名・期限・IAM・KMSの調べ方を整理する。

ノートと鉛筆のイラスト

ブラウザから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バケットへのアップロード検証は行っていないため、利用する署名発行処理やバケットポリシーに合わせて確認してほしい。