Skip to content
AnyStorage
Free Download

Amazon S3

S3 CORS Error: Fix Access-Control-Allow-Origin on a Bucket

A browser CORS failure against Amazon S3 is bucket configuration, not a code bug. The five CORS elements, the two 403 replies S3 sends, and a curl test.

What S3's two CORS 403 responses mean, how it matches a rule, the wildcard limits in AllowedOrigins and AllowedHeaders, and why ETag needs ExposeHeaders.

s3 cors error, s3 cors policy, s3 access-control-allow-origin

Your fetch is fine. Your credentials are fine. curl downloads the same object without complaint. The browser is refusing to hand the response to your JavaScript because the bucket never said it was allowed to. Everything below was checked against the Amazon S3 User Guide, the AWS CLI reference and MDN in September 2026. The Cloudflare sibling of this problem is on R2 CORS error, and the differences are real enough to be worth reading separately.

The browser is vague. S3 is specific.

What you see in the console is a generic failure. Chrome words it like this:

text

Access to fetch at 'https://my-bucket.s3.eu-west-1.amazonaws.com/key.png'
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Firefox words the same condition differently — MDN documents it as "Reason: CORS header 'Access-Control-Allow-Origin' missing", and explains that "The response to the CORS request is missing the required Access-Control-Allow-Origin header, which is used to determine whether or not the resource can be accessed by content operating within the current origin." Either way the browser is describing an absence, and an absence has several causes.

S3 is more useful, because it returns two distinguishable errors. The first:

text

HTTP/1.1 403 Forbidden
CORS Response: CORS is not enabled for this bucket.

That means there is no CORS configuration on the bucket at all. The second:

text

HTTP/1.1 403 Forbidden
CORS Response: This CORS request is not allowed.

That means there is a configuration and it does not match your request. AWS lists exactly three reasons for the second one: "Origin is not allowed", "Methods are not allowed", "Requested headers are not allowed". You will never see either message in the browser console — the browser hides the response body from a failed CORS request. You will see them with curl, which is why the test near the end of this page is the fastest route to an answer.

Which element to change, by symptom
SymptomWhat S3 is sayingElement
CORS is not enabled for this bucketNo configuration existsCreate one
Works for GET, 403 for PUTMethod not listedAllowedMethods
403 as soon as you set a JSON content typeHeader not listedAllowedHeaders
Upload succeeds, response.headers.get('etag') is nullHeader not exposedExposeHeaders
Works on example.com, fails on www.example.comOrigins compare exactlyAllowedOrigins
Fails again immediately after a fixPreflight cachedMaxAgeSeconds

How S3 chooses a rule

S3 "uses the first CORSRule rule that matches the incoming browser request", and a rule matches only when all three of these hold:

  1. The Origin header in the request matches an entry in AllowedOrigins.
  2. The method in Access-Control-Request-Method matches an entry in AllowedMethods.
  3. Every header listed in Access-Control-Request-Headers matches an entry in AllowedHeaders.

First match wins, so a broad rule placed above a narrow one will shadow it. A configuration may hold up to 100 rules, and in the S3 console "the CORS configuration must be JSON" — the XML form still works through the API and the SDKs, but the console will not accept it. ACLs and bucket policies keep applying: CORS decides whether the browser may read the response, never whether the caller is authorised.

The five elements, and the wildcard arithmetic

AllowedMethods accepts exactly five values: GET, PUT, POST, DELETE, HEAD. OPTIONS is not among them, because the preflight is answered by S3 rather than allowed by you.

The wildcard rules are where configurations quietly break. An AllowedOrigins entry "can contain only one * wildcard character, such as http://*.example.com", and each AllowedHeaders string "can contain at most one * wildcard character" — x-amz-* is the documented example and it enables all Amazon-specific headers. AWS's troubleshooting page also notes that a * in AllowedMethods matches all HTTP methods, which is not obvious from the list of five.

A configuration that covers a browser upload and a browser read looks like this:

json

[
  {
    "AllowedOrigins": ["https://app.example.com", "http://localhost:5173"],
    "AllowedMethods": ["GET", "HEAD", "PUT", "POST"],
    "AllowedHeaders": ["Content-Type", "x-amz-*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

MaxAgeSeconds is how long the browser may cache the preflight answer "as identified by the resource, the HTTP method, and the origin". Set it high and a fix you deploy will appear not to work until the cache expires; set it to zero while debugging and raise it afterwards.

The header nobody remembers: ETag

This is the single most common half-working CORS setup. The upload returns 200, the object is in the bucket, and your code cannot read the part number it needs. AWS states the rule directly: "if you want to read the ETag header from a PUT or multipart upload, you need to include the ExposeHeader tag in your configuration", and "The SDK can only access headers that are exposed through CORS configuration."

Browser-side multipart upload depends on this, because completing a multipart upload means sending back the ETag of every part. Without "ExposeHeaders": ["ETag"] the parts upload and the completion call fails. Custom metadata behaves the same way: values come back as x-amz-meta-* headers and must be listed too.

localhost, preview URLs and credentials

AllowedOrigins entries are compared as strings, not patterns beyond the single wildcard. http://localhost:3000 and http://localhost:5173 are two origins, http://localhost and https://localhost two more, and a trailing slash makes an entry match nothing. Preview origins are the usual reason a bucket "worked yesterday": each deployment gets a new hostname, and one wildcard entry such as https://*.example.dev fixes it.

Two limits come from the browser rather than from S3. MDN's rule for credentialed requests is absolute: the server "must not specify the * wildcard for the Access-Control-Allow-Origin response-header value, but must instead specify an explicit origin". And a request escapes the preflight only if it is simple: GET, HEAD or POST with nothing but CORS-safelisted headers — Accept, Accept-Language, Content-Language, Content-Type limited to three MIME types, and Range.

Test the preflight with curl

AWS publishes the command, so there is no reason to guess:

sh

curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type" \
  "https://my-bucket.s3.eu-west-1.amazonaws.com/key.png"

A correct configuration answers 200 OK with Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers and a Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method. One warning from the same page explains a lot of confusing output: "When sending a preflight request, if any of the CORS request headers are not allowed, none of the response CORS headers are returned." An empty-looking answer means one item in your request was rejected, not that the whole configuration is missing.

Apply the configuration

In the console: General purpose buckets, your bucket, Permissions, then Edit in the Cross-origin resource sharing (CORS) section, paste the JSON, Save changes.

From the CLI, which is the version you can keep in the repository:

sh

aws s3api put-bucket-cors --bucket my-bucket --cors-configuration file://cors.json
aws s3api get-bucket-cors --bucket my-bucket

The caller needs the s3:PutBucketCORS action, which the bucket owner has by default. If a CDN sits in front of the bucket, configure it too: allow OPTIONS, forward Origin, Access-Control-Request-Headers and Access-Control-Request-Method, and put the origin header in the cache key — AWS warns that "caching proxies that don't include the origin header in their cache key may serve cached responses that don't include the appropriate CORS headers for different origins."

Presigned URLs are not exempt

A presigned URL carries authorisation, not permission to read the response. The browser still runs its CORS check, so a presigned PUT from JavaScript needs the same bucket configuration as an unsigned one. Two expiry numbers matter while debugging, because an expired URL returns a 403 that looks like a CORS failure: the S3 console caps a presigned URL at 12 hours, aws s3 presign at 7 days. Our S3 presigned URL generator covers the signing side.

When CORS is not your problem

CORS protects browsers, so it constrains browsers only. A desktop client signs its own requests, sends no Origin header, and no bucket configuration can stop it — which is why a bucket that is unusable from your web app opens normally in an app in the same minute.

AnyStorage 0.2.25 (macOS 11+, Windows 10+, Ubuntu 20.04+) reaches S3 and S3-compatible endpoints with an access key pair, an optional custom endpoint URL and an explicit path-style toggle, and mounts them through a local WebDAV server rather than a kernel driver. The free tier allows two connections and serves that mount read-only. It is the right tool for moving the file and does nothing for your web app, which still needs the JSON above — see the S3 GUI client overview or the Windows edition.

Questions

Can I just set AllowedOrigins to "*"?

For a bucket of public assets, yes — ["*"] plus the methods you actually use is defensible. It stops being an option the moment the request carries credentials, because the browser forbids a wildcard Access-Control-Allow-Origin on a credentialed response. Also keep AllowedHeaders explicit: one wildcard per string is the documented limit, and x-amz-* is usually what you meant.

Why does the fix not take effect?

Almost always a cached preflight. The browser may reuse the previous answer for MaxAgeSeconds, keyed by resource, method and origin. Re-run the curl -X OPTIONS check to see what the bucket is answering right now, then retry in a fresh private window.

Do I need CORS for uploads from my server?

No. CORS applies to requests made by browser JavaScript. A Lambda function, a Node process, a CI job or a desktop client is never subject to it, which is why the same credentials work in one place and fail in the other.

How many CORS rules can a bucket have?

Up to 100, and the first rule that matches the request wins. Order matters: put the specific rules above the general ones, or a broad rule will answer requests you meant to handle differently.

My upload works but I cannot read the ETag. Is that CORS?

Yes, and it is one array. Add "ExposeHeaders": ["ETag"] to the matching rule. Browser-side multipart upload cannot complete without it, and any x-amz-meta-* header you want to read must be listed the same way.