概要と結論
ブラウザから S3 へ署名付きURL(presigned URL)で画像をアップロードしようとしたところ、PUT が 403 で失敗した。
結論から言うと、最終的な原因は、バックエンドのコンテナが有効ではないアクセスキーで動いていたことでした。あわせて、署名付きURLが古い署名方式(SigV2)の形式で発行されていたため、SigV4 を明示指定する修正も入れています。
ただし、次の2点は留意点。
- SigV4 に直したこと自体が、今回の失敗を直したのかどうかは検証できていません(認証情報が無効なままでは、どの署名方式でも失敗するため)
- 「なぜコンテナが古いキーで動いていたのか」は、
.envの更新がコンテナに反映されていなかっためだが、なぜそれが起きたかは厳密には特定できていません
この記事は、原因そのものよりも、403 の原因を層ごとに切り分けていった手順にあると思っています。
環境
- フロントエンド: Next.js 15
- バックエンド: FastAPI / Python 3.12(Docker Compose)
- boto3 1.34.0 / botocore 1.34.162
- S3 バケット・パブリックアクセスはすべてブロック
- 閲覧は CloudFront(OAC)経由、アップロードはブラウザから S3 へ直接 PUT
- 開発環境: macOS + Docker Desktop
構成
[アップロード] ブラウザ ──(署名付きURLで直接PUT)──▶ S3
[閲覧] ブラウザ ──▶ CloudFront ──(OACで読み取り)──▶ S3
- ブラウザがバックエンドの
POST /example/photoを呼ぶ - バックエンドが
boto3で署名付きURLを発行して返す - ブラウザがそのURLに画像を
PUTする
バックエンドの発行部分は次のような形です。
import boto3
s3 = boto3.client(
"s3",
region_name="example-reagion",
aws_access_key_id=ACCESS_KEY_ID,
aws_secret_access_key=SECRET_ACCESS_KEY,
)
upload_url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "example-bucket", "Key": key, "ContentType": content_type},
ExpiresIn=300,
)
症状
-
POST /example/photo(URL発行)は200 OK - その後、S3 への
PUTが403 Forbidden - ブラウザの開発者ツールでは、レスポンス本文が
Failed to load response dataと表示されてしまい、エラーの理由が読めない
原因
原因1: 署名付きURLが SigV2 形式だった
最初に発行されたURLはこのような形でした。
https://example-bucket.s3.amazonaws.com/bottles/xxxx?AWSAccessKeyId=AKIAXXXXXXXXXXXXXXXX&Signature=...&Expires=...
AWSAccessKeyId / Signature / Expires などのクエリは、旧署名方式(Signature Version 2)。現在は SigV4 が前提なので、クライアント作成時に署名バージョンを明示して対応。
from botocore.client import Config
s3 = boto3.client(
"s3",
region_name="example-reagion",
aws_access_key_id=ACCESS_KEY_ID,
aws_secret_access_key=SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
これでURLは X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=... という SigV4 形式に。
原因2: コンテナが有効ではないアクセスキーで動いていた
SigV4 にしても 403 は消えず。最終的に、次の節の切り分けにより InvalidAccessKeyId(アクセスキーがAWSに存在しない)にたどり着きました。
切り分けの手順
1. curl で PUT する(ブラウザと CORS を除外)
CORS はブラウザだけのチェックなので、curl は無視できる。
curl -i -X PUT \
-H "Content-Type: image/jpeg" \
--data-binary @/path/to/test.jpeg \
"<upload_url>"
※-i を付けると、開発者ツールでは見えないレスポンス本文が読めます。
2. 送信内容の確認
SignatureDoesNotMatch のエラー XML には、S3 側が再計算した CanonicalRequest が入っています。ここでメソッドやヘッダーを確認できます。
PUT
/bottles/xxxx
X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-SignedHeaders=content-type%3Bhost
content-type:image/jpeg
host:example-bucket.s3.amazonaws.com
content-type;host
UNSIGNED-PAYLOAD
- メソッドは
PUT、content-typeも署名時の値と一致
⇨リクエスト内容は正しく、署名の計算に使う値(キー)側が怪しいのでは?
3. boto3 で直接 put_object する(認証情報そのものを確認)
署名付きURLの仕組みを外し、同じ認証情報で直接書き込めるかを検証。
docker compose exec backend python -c "
import boto3
from config import settings as s
c = boto3.client('s3', region_name=s.AWS_S3_BUCKET_REGION,
aws_access_key_id=s.AWS_S3_UPLOADER_ACCESS_KEY_ID,
aws_secret_access_key=s.AWS_S3_UPLOADER_SECRET_ACCESS_KEY)
c.put_object(Bucket=s.AWS_S3_BUCKET, Key='test/hello.txt', Body=b'hello')
print('OK')
"
結果は次の通り。
botocore.exceptions.ClientError: An error occurred (InvalidAccessKeyId) when calling the PutObject operation:
The AWS Access Key Id you provided does not exist in our records.
これによって「アプリが使っているアクセスキーが、AWS上に存在しない」と確定しました。
4. 認証情報を読み込み直す
有効なキーがコンテナに読み込まれるようにして、もう一度 put_object を試すと OK になり、その後アプリからのアップロードも通りました。
まとめ・教訓
- 403 は「リクエスト内容」「署名の計算」「認証情報」のどこでも起きる可能性がある。外側から順に層を外して切り分けていくと、適当に推測するより確実です。
- 認証情報を差し替えた時、アプリが実際に読んでいる値を確認する習慣をつけると、同じ罠を避けらる
- SigV2 の URL が発行されたら、SigV4 の明示指定を入れておくのが無難。