Skip to content

S3 CORS errors: configuring cross-origin access that actually works

Learn how to fix S3 CORS errors so that browser uploads and downloads from your bucket work again. This guide shows you how to set the bucket CORS rules, match the page origin exactly, and stop CloudFront from caching a response that carries no CORS headers.

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 AllowedOrigins byte 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 OPTIONS request separately — if that one fails, look at AllowedMethods and AllowedHeaders.

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

  1. 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.

  1. Keep AllowedHeaders wide for uploads. Presigned PUT uploads send Content-Type and often x-amz-* headers; listing them individually is a maintenance treadmill. "AllowedHeaders": ["*"] is fine — remember CORS is not your security boundary.

  2. Add ExposeHeaders for anything JavaScript reads. Multipart upload code that reads ETag from the response will get null in the browser unless ETag is exposed, even though the request succeeded.

  3. Fix the CloudFront layer. Use a cache policy that includes the Origin header in the cache key (the managed CORS-S3Origin origin request policy plus a cache policy with Origin, Access-Control-Request-Method, and Access-Control-Request-Headers), allow the OPTIONS method on the behavior, and invalidate after changing: cached wrong answers outlive configuration fixes.

  4. 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 in AllowedOrigins — the match is exact, and the failure looks identical to having no configuration.
  • Testing with curl without an Origin header, 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 MaxAgeSeconds to 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 OPTIONS preflight 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.