This article shows you how to fix CORS errors on an Amazon S3 bucket, and it is for anyone looking after a web app that uploads or downloads files straight from one. CORS, short for cross-origin resource sharing, is the browser rule that decides whether a page on one domain may read a file served from another. You meet it as the console message “No ‘Access-Control-Allow-Origin’ header is present”, usually right after an upload or an image stops working. Nearly every case comes down to a bucket with no CORS configuration, rules that do not match the request exactly, or CloudFront serving a cached response that carries no CORS headers. Check the response status before anything else, because a 403 is a permissions problem in a CORS costume that no CORS rule will fix.
What’s happening
CORS is a browser-enforcement mechanism, not a security control on the bucket. When JavaScript on https://app.example.com requests an object on https://my-bucket.s3.ca-central-1.amazonaws.com, the browser demands that S3’s response include Access-Control-Allow-Origin matching the page’s origin. For “non-simple” requests — a PUT upload, or anything with custom headers like x-amz-meta-* or an explicit Content-Type — the browser first sends a preflight OPTIONS request, and S3 must answer that with the allowed methods and headers before the real request is even attempted.
S3 only emits CORS headers when two conditions hold: the request carries an Origin header, and that origin matches an AllowedOrigins entry exactly — scheme included, port included, no trailing slash. https://app.example.com does not match http://app.example.com or https://www.app.example.com. A wildcard * matches everything but cannot be combined with credentialed requests, and https://*.example.com (one wildcard allowed per origin) covers subdomains.
The trap that survives a correct configuration: CloudFront. If the distribution’s cache policy does not include Origin in the cache key and does not forward it upstream, CloudFront caches one response — often one fetched without an Origin header, so no CORS headers — and serves it to every browser afterwards.
What to check first
- Is the failing response actually a 403 or 404? Open the failed request in DevTools > Network. An error status means the problem is permissions, not CORS — browsers report any cross-origin error status as a CORS failure.
- Does the bucket have a CORS configuration at all? New buckets have none.
- Does the page origin match
AllowedOriginsbyte for byte? Check scheme, host, and port. - Is the request going through CloudFront or straight to the S3 endpoint? The fix differs.
- Is it a preflight failure? DevTools shows the
OPTIONSrequest separately — if that one fails, look atAllowedMethodsandAllowedHeaders.
How to verify
Read the current configuration:
aws s3api get-bucket-cors --bucket my-bucket
Reproduce the preflight without a browser — this is the fastest debug loop:
curl -i -X OPTIONS "https://my-bucket.s3.ca-central-1.amazonaws.com/test.jpg" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type"
A working setup returns 200 with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers echoed back. A 403 with AccessForbidden or a response with no CORS headers tells you the rule does not match this exact combination. Test the simple GET case too:
curl -i "https://my-bucket.s3.ca-central-1.amazonaws.com/test.jpg" \
-H "Origin: https://app.example.com" | grep -i access-control
Console path: S3 > Buckets > your-bucket > Permissions > Cross-origin resource sharing (CORS).
The fix
- Apply a configuration that matches reality. For the common case — a web app that downloads and uploads directly to S3 (including presigned PUT uploads):
[
{
"AllowedOrigins": ["https://app.example.com", "https://staging.example.com"],
"AllowedMethods": ["GET", "HEAD", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-version-id"],
"MaxAgeSeconds": 3000
}
]
Apply with aws s3api put-bucket-cors --bucket my-bucket --cors-configuration file://cors.json. Note put-bucket-cors replaces the entire configuration — merge, do not overwrite blindly.
-
Keep
AllowedHeaderswide for uploads. Presigned PUT uploads sendContent-Typeand oftenx-amz-*headers; listing them individually is a maintenance treadmill."AllowedHeaders": ["*"]is fine — remember CORS is not your security boundary. -
Add
ExposeHeadersfor anything JavaScript reads. Multipart upload code that readsETagfrom the response will getnullin the browser unlessETagis exposed, even though the request succeeded. -
Fix the CloudFront layer. Use a cache policy that includes the
Originheader in the cache key (the managedCORS-S3Originorigin request policy plus a cache policy withOrigin,Access-Control-Request-Method, andAccess-Control-Request-Headers), allow theOPTIONSmethod on the behavior, and invalidate after changing: cached wrong answers outlive configuration fixes. -
If it is a 403 underneath, fix the 403. Presigned URL expired, signature mismatch, Block Public Access — no CORS rule changes an error status.
When this is the wrong move
Do not use CORS rules as access control — they only instruct browsers, and curl ignores them entirely. And if the object is fetched via an <img> tag, a download link, or server-side code, CORS is not involved at all; debugging it there is wasted time. If you control both origins, consider serving the assets through the same CloudFront distribution as the app — same-origin requests make the whole problem vanish.
Common mistakes
- A trailing slash or
www.mismatch inAllowedOrigins— the match is exact, and the failure looks identical to having no configuration. - Testing with
curlwithout anOriginheader, seeing no CORS headers, and concluding the config is broken — S3 only sends them when an origin is presented. - Fixing the bucket but not invalidating CloudFront, then declaring the fix failed.
- Setting
MaxAgeSecondsto a day during debugging — the browser caches the old preflight verdict and ignores your fix until it expires.
Prevention
- Keep the CORS configuration in IaC next to the bucket definition, with staging and production origins listed explicitly.
- Add the
curl -X OPTIONSpreflight check to your deployment smoke tests for any bucket browsers touch. - Route browser traffic through CloudFront with a CORS-aware cache policy from day one instead of exposing raw bucket endpoints.
- Document which apps upload directly to which buckets, so origin changes (new domain, new environment) trigger a CORS update. The full CORS behavior reference is at https://docs.aws.amazon.com/AmazonS3/ under the CORS section.
CORS failures are deterministic: exact origin match, correct methods and headers, and an honest look at whether the underlying status was an error. Walk those three and it works. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.