If you’re generating S3 presigned URLs in a backend and fetching them from a browser, there’s a good chance you’ll hit a CORS error that has nothing to do with your CORS configuration. The error is confusing, the root cause is non-obvious, and the fix isn’t what you’d expect.
The Symptom #
You have an S3 bucket with CORS properly configured. Your backend generates a presigned URL using boto3. Your frontend fetches it. The browser console says:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource...
(Reason: CORS request did not succeed). Status code: (null).The Network tab shows “CORS Missing Allow Origin” in the Transferred column, and the response has a Location header pointing to a different S3 endpoint. Your CORS config is correct. The file exists. What’s going on?
The Root Cause #
boto3’s S3 client generates presigned URLs using the global endpoint s3.amazonaws.com, even when a region is configured. This is S3-specific behavior in boto3 – every other AWS service uses the regional endpoint.
When the browser requests https://my-bucket.s3.amazonaws.com/my-file, S3’s routing layer returns a 307 Temporary Redirect to the regional endpoint https://my-bucket.s3.ca-central-1.amazonaws.com/my-file. This redirect is generated before S3 evaluates the bucket’s CORS rules, so the 307 response has no CORS headers.
Per the Fetch spec, the browser checks CORS on every response in the chain, including redirects. No Access-Control-Allow-Origin on the 307 means the browser blocks the redirect immediately. It never reaches the regional endpoint where CORS would work fine.
Why It’s Confusing #
-
Your CORS config is correct. The bucket allows your origin. If you
curlthe regional URL with anOriginheader, you get a 200 with proper CORS headers. -
The region is already set.
AWS_DEFAULT_REGIONandAWS_REGIONare both set in the environment. boto3 uses the region for the credential scope in the signature, but still generates a global endpoint URL. The region is in the presigned URL – just not in the hostname. -
It works from the SDK. When boto3 itself makes S3 requests, it handles the 307 internally. You never see it. The problem only surfaces when handing a presigned URL to a browser.
-
The browser lies about the error. The real issue is a redirect with missing headers, but Firefox shows “CORS Missing Allow Origin” and Chrome says “CORS policy.” If S3 included CORS headers on the 307, the whole problem would disappear.
-
It might work in production but not in dev/staging. S3’s behavior around the global endpoint can vary by bucket age and region. Newer buckets and non-us-east-1 regions are more likely to get the 307.
What Doesn’t Fix It #
Based on boto3 issue #421 (open since 2015), several commonly suggested fixes don’t work reliably:
Passing region_name to the client:
# Still generates s3.amazonaws.com URLs
boto3.client('s3', region_name='ca-central-1')Adding Config(signature_version='s3v4'):
# Still generates s3.amazonaws.com URLs
boto3.client('s3', region_name='ca-central-1', config=Config(signature_version='s3v4'))We verified both of these inside a running ECS container. The presigned URL still came back with s3.amazonaws.com.
What Actually Fixes It #
Use endpoint_url to force the regional endpoint:
import boto3
from django.conf import settings
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
endpoint_url=f"https://s3.{settings.AWS_REGION}.amazonaws.com",
)
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket_name, "Key": key},
)This generates URLs like https://s3.ca-central-1.amazonaws.com/my-bucket/my-file?X-Amz-... – no redirect, CORS works.
Alternatively, proxy the download server-side. If your frontend is Next.js, a server action can fetch the presigned URL on the server (where CORS doesn’t apply) and pipe the response to the client:
"use server"
export async function downloadFile(presignedUrl: string) {
// Server-side fetch -- no CORS
const response = await fetch(presignedUrl)
const buffer = await response.arrayBuffer()
return Buffer.from(buffer).toString("base64")
}The Deeper Problem #
S3 is the only AWS service where boto3 defaults to a global endpoint. This is a legacy compatibility decision. The 307 redirect works fine for SDK-to-SDK communication because the SDK handles it transparently. But in the era of presigned URLs consumed by browsers, it’s a trap.
AWS could fix this by adding CORS headers to 307 redirect responses. The boto3 issue has been open for over a decade. In the meantime, always use endpoint_url when generating presigned URLs that will be fetched by a browser.