S3 CORS Error: The Exact JSON and Every Fix, in Order

Logeshwaran
—

If your browser console says No 'Access-Control-Allow-Origin' header is present on the requested resource when a page loads a file from S3, the bucket has no CORS rule that matches the request. Open the bucket, choose Permissions, find Cross-origin resource sharing (CORS), choose Edit, and paste the JSON from the first section below with your own site in AllowedOrigins. Here is the part that surprises people: the match is character for character. A rule for http://www.example.com does nothing for https://www.example.com, so a rule that looks right can still fail.

⚡ Quick Answer

• Console → S3 → General purpose buckets → your bucket → Permissions → Cross-origin resource sharing (CORS) → Edit → paste the JSON list → Save changes.

• CLI → aws s3api put-bucket-cors --bucket biscuit-price-lists --cors-configuration '{"CORSRules":[{"AllowedOrigins":["https://www.example.com"],"AllowedMethods":["GET","HEAD"],"AllowedHeaders":["*"]}]}'

Copy the origin from the failing request, not from memory. The console wants a bare list and the CLI wants the list wrapped in CORSRules; the full JSON shows both.

Jake's trade-in page went blank at 9 a.m. on a Saturday. The price list it loads sits in an S3 bucket he named after his cat, and the browser refused to show a single number from it. By lunch he figured he'd lost eleven trade-ins at about $35 margin each, which is $385, and nothing on the page told him why.

The bucket wasn't broken, the file wasn't private, and the page code was fine. What was missing was one small JSON document stored on the bucket. That document is short, but every character in it is compared against what the browser sends, so it pays to know exactly what each line does. Ethan walked Jake through it, and this page follows the same order: what the error means, the JSON, both routes to save it, and the cases that survive a correct rule.

What the S3 CORS error actually means

The error message uses three terms as if you already knew them, so here they are in plain words.

CORS stands for cross-origin resource sharing. It's the browser's house rule that a web page loaded from one address may only read data from a different address if that other address says yes. An origin is the page's address boiled down to three parts: the scheme (https or http), the host (like www.jakesphones.example), and the port, which is the number after a colon when there is one. A bucket is S3's top-level container, the storage room, and every file inside is an object.

When your page asks a bucket for a file from a different origin, the browser adds an Origin header to the request. That's a line of small print saying where the page came from. S3 reads that line and compares it with the CORS rules stored on the bucket. If a rule matches, S3 answers with CORS headers and the browser lets your code read the file. If no rule matches, or the bucket has no rules at all, S3 doesn't return the CORS header, and the browser shows the error you searched for.

"So S3 is refusing me?" Jake asked.

"Not exactly," Ethan said. "The file can be perfectly readable. S3 just never says out loud that your page is on the guest list, so the browser won't hand the answer to your code. The lock is on the browser's side of the door."

One more detail trips people up. If a request has no Origin header, S3 doesn't treat it as a cross-origin request and sends no CORS headers at all. That matters when you test with curl, and it's why a quick test can lie to you. I come back to it in the CLI section.

These are the exact strings you'll meet, and what each one points to:

Exact text Where you see it What it means
No 'Access-Control-Allow-Origin' header is present on the requested resource Browser developer console The response came back without the CORS header: no rule, no matching rule, or something in front of S3 (like CloudFront) hid it.
HTTP/1.1 403 Forbidden CORS Response: CORS is not enabled for this bucket. Response to a cross-origin request The bucket has no CORS configuration at all. Add one.
HTTP/1.1 403 Forbidden CORS Response: This CORS request is not allowed. Response to a cross-origin request or a preflight A configuration exists, but no rule matches the origin, the method, or the requested headers.
<Code>AccessForbidden</Code> with a CORSResponse message XML body of that same 403 Same as the row above. The body also names the Method and ResourceType that failed.
No error, but a header your script needs is missing Your JavaScript The request worked, but the header isn't listed in ExposeHeaders.

Popular advice says to make the bucket public and the error will go away. That's wrong, and it's a risky way to be wrong. A CORS rule grants nothing. When you enable CORS on a bucket, the ACLs (access control lists, the per-object permission lists) and every other access policy keep applying exactly as before. Permissions decide whether S3 hands over the file. CORS decides whether the browser lets your page read what S3 handed over. You need both to say yes.

The exact JSON that fixes an S3 CORS error

Start with the most common situation: a public site that only reads files from the bucket, such as images, a JSON price list, or a script. Swap in your own origin.

[
  {
    "AllowedOrigins": ["https://www.jakesphones.example"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]

That's a list with one rule in it. Here is what each line does:

  • AllowedOrigins is the guest list. Each entry is a full origin: scheme, host, and port if there is one. An entry can contain one * wildcard, as in https://*.jakesphones.example. A lone * allows every origin, and writing https in the entries limits you to secure origins.
  • AllowedMethods is what the page may do. The allowed values are GET, PUT, POST, DELETE, and HEAD, and nothing else.
  • AllowedHeaders lists the request headers the browser may send. During a preflight, every header the browser asks about has to match an entry here. Each entry can hold one *, so x-amz-* covers all the Amazon-specific headers.
  • ExposeHeaders (not used above) lists response headers your JavaScript is allowed to read.
  • MaxAgeSeconds is how long the browser may remember the preflight answer. 3000 seconds is 50 minutes.

A preflight is the browser's ask-first request. Before certain cross-origin requests, it sends an OPTIONS request that amounts to "would you accept a PUT from this origin with these headers?" S3 answers using the first rule in the configuration that matches. For a rule to match, the origin, the method, and every requested header all have to match. A rule that gets two out of three doesn't match.

The same rule, for browser uploads

If your page sends files to the bucket, add the write methods and expose the headers your script has to read afterward:

[
  {
    "AllowedOrigins": ["https://www.jakesphones.example"],
    "AllowedMethods": ["PUT", "POST", "DELETE"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "x-amz-request-id", "x-amz-id-2"],
    "MaxAgeSeconds": 3000
  }
]

The same rule, for files anyone may load (fonts, public images)

[
  {
    "AllowedOrigins": ["*"],
    "AllowedMethods": ["GET"],
    "AllowedHeaders": []
  }
]

✅ Why this is the one to use

Use the first block, with your exact origin, unless you have a real reason not to. It allows reading and nothing else. Ethan's rule is one rule per job: a read rule, an upload rule, an open-font rule. Keep them from overlapping on the same origin and method, because when two rules could match, the first one in the list is the one S3 uses.

Jake's final file: one read rule, one upload rule

Real buckets end up with more than one rule. Here is the whole list for a public site plus a separate admin page that uploads:

[
  {
    "AllowedOrigins": ["https://www.jakesphones.example"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  },
  {
    "AllowedOrigins": ["https://admin.jakesphones.example"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

The public site can read but can't write. Only the admin page can upload, and only it can read the ETag. The two rules name different origins, so they never compete for the same request. The list can grow to 100 rules.

Console, CLI, CloudFormation: same rule, different envelope

This is where "it works in the console but not in the CLI" comes from. The rule is identical everywhere, but the wrapper around it and a couple of property names change:

Where you paste it Wrapper Property names to watch
S3 console editor A bare list: [ {...} ]. Console accepts JSON only. ExposeHeaders, MaxAgeSeconds
aws s3api put-bucket-cors An object with a CORSRules key holding the list ExposeHeaders, MaxAgeSeconds
CloudFormation CorsConfiguration A CorsRules list ExposedHeaders, MaxAge, and an optional Id
SDK code (Java sample) Rule objects added to a configuration object withExposedHeaders, withMaxAgeSeconds
SDK code (.NET sample) A Rules list on the configuration ExposeHeaders, MaxAgeSeconds

🙋‍♂️ Jake's Reality Check

"I copied the JSON out of the console and pasted it into the CLI and it just sat there. Same rule, right?"

The straight answer. Same rule, wrong envelope. The console takes the bare list. The CLI wants {"CORSRules": [ ... ]}. Wrap it or unwrap it depending on where you're pasting.

Console route: where the CORS editor lives

  1. Sign in to the AWS Management Console and open the Amazon S3 console.
  2. In the left navigation pane, choose General purpose buckets.
  3. In the buckets list, choose the bucket the browser is asking for files from. It's the name at the front of the failing request's address, like biscuit-price-lists.s3.amazonaws.com.
  4. Choose the Permissions tab.
  5. Scroll to Cross-origin resource sharing (CORS) and choose Edit.
  6. In the CORS configuration editor box, paste the JSON list. If rules are already there, edit them instead of pasting over them, so you don't delete a rule another page depends on.
  7. Choose Save changes.

Next to the editor title, S3 shows the bucket's ARN. An ARN (Amazon Resource Name) is the bucket's full address label inside AWS. It's a handy way to confirm you're editing the bucket you think you are, especially when you have prices, prices-old, and prices-final.

If Save changes fails, one reason is text that isn't valid JSON: a trailing comma, a missing bracket, or XML pasted into a box that only takes JSON. Run the text through any JSON checker and save again. The other reason is permission. Changing or deleting a bucket's CORS configuration needs the s3:PutBucketCORS permission, and reading it needs s3:GetBucketCORS. The bucket owner has both by default and can grant them to others, so if the bucket isn't yours, ask whoever owns it.

CLI route: put-bucket-cors, get-bucket-cors, and a curl check

The CLI is the AWS Command Line Interface, the same controls as the console, typed instead of clicked. This is the same read rule from above, wrapped for the CLI:

  1. Read what's already there, so you can carry existing rules over:
    aws s3api get-bucket-cors --bucket biscuit-price-lists
  2. Save the configuration:
    aws s3api put-bucket-cors --bucket biscuit-price-lists --cors-configuration '{"CORSRules":[{"AllowedOrigins":["https://www.jakesphones.example"],"AllowedMethods":["GET","HEAD"],"AllowedHeaders":["*"],"MaxAgeSeconds":3000}]}'
  3. Ask S3 the simple question, sending the same Origin your page sends:
    curl -i https://biscuit-price-lists.s3.amazonaws.com/price-list.json -H "Origin: https://www.jakesphones.example"
  4. Ask the preflight question, naming the method the page will use:
    curl -i -X OPTIONS https://biscuit-price-lists.s3.amazonaws.com/price-list.json -H "Origin: https://www.jakesphones.example" -H "Access-Control-Request-Method: GET"

Treat put-bucket-cors as a full replacement. Send every rule you want to keep. The safe pattern, in the CLI or in code, is to fetch the current configuration, add your rule to it, and set the whole thing back.

If you'd rather keep the JSON in a file, save it as cors.json with the CORSRules wrapper and point the command at it:

aws s3api put-bucket-cors --bucket biscuit-price-lists --cors-configuration file://cors.json --expected-bucket-owner 111122223333

The --expected-bucket-owner flag is optional. If the account ID doesn't match the bucket's real owner, the request fails with 403 Forbidden before anything changes, which is a cheap way to learn you're pointed at the wrong account's bucket.

What a working answer looks like

A matching request comes back with 200 OK and a set of CORS headers, roughly like this:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.jakesphones.example
Access-Control-Allow-Methods: GET, HEAD
Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
Server: AmazonS3

A request that no rule matches gets the failure from the table above: 403 Forbidden with AccessForbidden in the XML body. There's a nuance for preflights. If any of the CORS request headers isn't allowed, none of the CORS response headers come back, so a single missing header name can make the whole answer look empty.

The curl trap

Run curl -i on the file with no -H "Origin: ..." and you'll see no CORS headers, even on a bucket with a perfect rule. That's because S3 only treats a request as cross-origin when it carries an Origin header. So a bare curl proves nothing either way. Always send the origin, and make it the one the browser sends.

Removing the configuration

aws s3api delete-bucket-cors --bucket biscuit-price-lists

⚠️ What this actually breaks

delete-bucket-cors removes the whole configuration, every rule, not just the one you were staring at. Any other site or app that reads from this bucket starts getting CORS is not enabled for this bucket the next time it loads. Run get-bucket-cors first and keep a copy.

First, find the origin your browser really sends

When "CORS is still not working after I added the rule", start with one question: does the entry in AllowedOrigins equal the Origin header the browser actually sent? The scheme, the host, and the port all have to match. That's why a rule for http://www.example.com doesn't cover https://www.example.com, and it doesn't cover http://www.example.com:80 either.

"But those are the same website," Jake said.

"To you," Ethan said. "The guest list is read literally. The bouncer doesn't care that you're obviously the same Robert who was on the list last week, because the list says Bob."

An origin has no path in it, only scheme://host plus the port when there is one. Here's how page addresses turn into the entries you need:

Address in the browser bar Origin the browser sends What to put in AllowedOrigins
https://www.jakesphones.example/trade-in/ https://www.jakesphones.example Exactly that, with no path
http://www.jakesphones.example/ http://www.jakesphones.example A separate entry from the https one
https://jakesphones.example/ (no www) https://jakesphones.example Its own entry. www and no-www are different hosts.
http://localhost:3000/ http://localhost:3000 Include the port. :3001 would be another entry.
https://book.jakesphones.example/ https://book.jakesphones.example Exact entry, or https://*.jakesphones.example with its one wildcard

To see the real value instead of guessing, capture the failing request with a tool of your choice, such as your browser's developer tools, and read the Origin line in the request headers. Copy it character for character into the rule. Guessing from the address bar is how a stray www ends up costing an afternoon.

If you'd rather test the rule than trust your eyes, the curl commands from the CLI section do it. Send -H "Origin: ..." with each origin your site can appear under and see which ones get an Access-Control-Allow-Origin line back.

Every cause, cheapest fix first

Work down this list in order. The first rows are one-line fixes; the later ones need a change somewhere other than the bucket.

Cause How you can tell Fix
1. The bucket has no CORS configuration 403 Forbidden with CORS is not enabled for this bucket; the console shows an empty CORS section Add the JSON list from above
2. The origin doesn't match This CORS request is not allowed; the Origin header differs by scheme, host, or port from your entry Add the exact origin as its own entry
3. The method isn't allowed Preflight for PUT, POST, or DELETE fails while GET works Add the method to AllowedMethods
4. A requested header isn't allowed Preflight fails; Access-Control-Request-Headers lists a name your rule doesn't Add that header, or use "*" in AllowedHeaders
5. The header exists but your script can't read it The request returns 200 OK, yet ETag or an x-amz-meta-* header is missing in JavaScript List it in ExposeHeaders
6. A proxy such as CloudFront sits in front The bucket answers correctly to curl, but the browser hits a CloudFront or custom-domain address and still fails Fix the distribution; see the CloudFront section
7. Two rules overlap The behavior matches an earlier rule, not the one you just added Merge them, or reorder so the rule you mean comes first

Check cause 2 first, because it costs one line to fix and it's the easiest one to get wrong by eye. Cause 1 is what you see the first time you ever set this up. Causes 3 and 4 show up the moment a page starts uploading. Cause 5 is the quiet one: nothing errors, a value just comes back empty.

A 60-second decision path

  1. In your browser's developer tools, open the failing request and copy two things: the request address and the Origin value.
  2. Run curl against the bucket's address with that exact Origin, even if the browser is really going through CloudFront. This splits the problem in half.
  3. If the bucket answers with Access-Control-Allow-Origin set to your origin, the rule is fine. Look at what sits in front (CloudFront, a custom domain, a cached response) or at a remembered preflight.
  4. If you get 403 with CORS is not enabled for this bucket, add a configuration. If you get This CORS request is not allowed, send the preflight curl with Access-Control-Request-Method and any request headers to see which of the three (origin, method, header) fails.
  5. If you get a plain 200 OK with no CORS headers, no rule matched. A simple request that matches nothing isn't always a 403, and it's the browser that then blocks your page.

Two rules, one request: which one wins

Say the bucket holds these two rules, both for the same origin:

[
  {
    "AllowedOrigins": ["https://www.jakesphones.example"],
    "AllowedMethods": ["GET"],
    "MaxAgeSeconds": 300
  },
  {
    "AllowedOrigins": ["https://www.jakesphones.example"],
    "AllowedMethods": ["GET", "PUT"],
    "ExposeHeaders": ["ETag"]
  }
]

A GET from that origin matches the first rule, and S3 stops there. The second rule's ExposeHeaders never applies to that request, so a script that reads ETag after a GET comes up empty. A PUT skips the first rule because the method isn't listed, then matches the second. The fix is to merge the two into one rule, so every setting you meant to apply to a request lives in the rule that request matches.

When you fixed it and nothing changed

Check these before you decide the rule is wrong. You may be editing the wrong bucket, since the bucket name is the part of the request address before .s3. The rule you saved may have replaced one you meant to keep. If the files come through CloudFront, an old response may still be cached. And a browser can remember a preflight answer for as long as MaxAgeSeconds says, so if you tightened a rule and the old behavior seems to linger, the remembered answer is a suspect.

When the browser talks to CloudFront instead of S3

CloudFront is AWS's content delivery service. It keeps copies of your files at locations near visitors and hands them out from there, and it's a proxy, meaning a middleman between the browser and S3. A perfect bucket rule can still fail here, because the middleman decides which request headers reach S3 and which cached answer goes back.

The first thing to do is find out who is talking. Run curl against the bucket address and again against the CloudFront address, with the same Origin. If the bucket returns Access-Control-Allow-Origin and CloudFront doesn't, the problem is in the distribution. Then work through these changes in order:

  1. Open your distribution in the CloudFront console and choose the Behaviors tab.
  2. Choose Create behavior, or select the existing behavior and choose Edit.
  3. Under Cache key and origin requests, choose Cache policy and origin request policy (recommended). For the origin request policy, pick CORS-S3Origin. It forwards Origin, Access-Control-Request-Headers, and Access-Control-Request-Method to S3, with no cookies and no query strings. By default CloudFront forwards only Origin.
  4. Under Allowed HTTP methods, choose GET, HEAD, OPTIONS. CloudFront allows only GET and HEAD by default, and browsers send preflights as OPTIONS.
  5. Make sure the Origin header is part of the cache key. In a cache policy, that's under Cache key settings: for Headers, choose Include the following headers and add Origin. A caching proxy that leaves it out can serve a cached response without the CORS headers to a different origin.
  6. Choose Save changes. Changes to a distribution deploy within about five minutes.
  7. Invalidate the cache so old responses stop being served. In the console, open the distribution, go to Invalidations, and choose Create invalidation.

The CLI version of step 7 is:

aws cloudfront create-invalidation --distribution-id E1A2B3C4D5EXAMPLE --paths "/*"

Keep the quotes around /* when you use the CLI. Invalidation paths are case sensitive, so a narrower path list has to match the exact spelling of each file.

⚠️ What this actually breaks

If you forward headers but leave Origin out of the cache key, the first visitor's answer can be handed to every other origin from the cache. One site works, the other doesn't, and it flips depending on who asked first. That pattern is the signature of a missing cache key entry.

The other way: let CloudFront send the CORS headers

If you can't change the bucket, or you'd rather not, CloudFront can return the CORS headers itself. In the behavior, set Response headers policy to an existing policy or choose Create policy, and under Cross-origin resource sharing turn on Configure CORS. My advice is to pick one owner for CORS, either the bucket or the distribution. When both try to answer, you get a puzzle at 9 a.m. instead of a rule.

✅ Why this is the one to use

Bucket-owned CORS with the CORS-S3Origin policy is the least surprising setup, because the rule sits beside the files it protects. Use a response headers policy only when you can't touch the bucket. Ethan's line: "Two owners for one door means nobody knows who locked it."

Uploads: PUT, headers, the ETag trap, and the preflight cache

Reading files needs GET. Sending them needs PUT or POST, and that's where two more things start to matter: the headers your upload code adds, and the response headers your code reads afterward. If your app signs an upload link (a presigned URL, meaning a link that carries temporary permission with it) and the browser sends the upload from your site, the CORS rule still has to allow the method and every header the browser attaches.

The headers part is exact. Every name in the preflight's Access-Control-Request-Headers has to match an entry in AllowedHeaders. If your upload sends Content-MD5 and your rule lists only Authorization, the preflight gets a 403 Forbidden. You'd need both names in the list. The lazy and dependable version is "AllowedHeaders": ["*"], which matches whatever the browser asks about. If you'd rather stay narrow, one * per entry is allowed, as in x-amz-*.

The ETag trap

Here's the quiet failure. The upload succeeds, the file lands in the bucket, and your script can't see the ETag header it needs to finish a multipart upload. By default, browsers block JavaScript from reading custom response headers, and S3 returns only the ones you list in ExposeHeaders. To read the ETag from a PUT or a multipart upload, add "ETag" there. The same goes for user-defined metadata: an x-amz-meta-custom-header value on an object stays hidden until you name it in ExposeHeaders.

Two details save time. First, a header missing from ExposeHeaders doesn't cause an error. The response is still 200 OK, and the header is just absent. Second, list each header by name. Ethan's habit is to write the list by reading the code that consumes the response, not by guessing.

What MaxAgeSeconds does, in numbers

MaxAgeSeconds is how long the browser remembers a preflight answer, and the memory is keyed to three things: the resource, the HTTP method, and the origin. Set it to 3000 and the browser can reuse that answer for 50 minutes without asking again.

Read literally, that key includes the resource. So a preflight remembered for PUT on photo-1.jpg doesn't obviously cover PUT on photo-2.jpg. A page that uploads 60 different photos can therefore send up to 60 preflights before the 60 uploads. That's normal, so don't mistake it for a bug, and don't expect a bigger MaxAgeSeconds to collapse them into one.

🙋‍♂️ Jake's Reality Check

"My customers upload a photo of the cracked screen before they bring the phone in. The photo lands in the bucket, but the page says the upload failed. What gives?"

The straight answer. Check whether the failure is the upload or the reading. If the object is in the bucket, the PUT was allowed, and your script probably couldn't read ETag from the response. Add it to ExposeHeaders.

Static website hosting, web fonts, and the two S3 addresses

A bucket has more than one address, and each address is a different origin. Suppose a site is hosted straight from a bucket named website, and visitors load http://website.s3-website.us-east-1.amazonaws.com. If JavaScript on those pages makes authenticated GET and PUT calls to the same bucket through its API address, website.s3.us-east-1.amazonaws.com, that's a cross-origin request even though it's "the same bucket". The bucket needs a rule that lists http://website.s3-website.us-east-1.amazonaws.com in AllowedOrigins.

The website address doesn't support HTTPS. If you want HTTPS in front of a bucket, put CloudFront in front of the bucket's REST API address and go back to the CloudFront section. That also changes your origin: a page served as https://www.jakesphones.example needs the https entry, not the http one you wrote for the website address.

You can also serve the site from your own domain, like example1.com, instead of the raw S3 address. The rule then lists that domain as the origin. Whatever address the visitor sees in the browser bar is the address that goes on the guest list.

Web fonts

Browsers run the CORS check on web fonts. If you host a font file in a bucket and a page on another origin uses it, the font won't load without a rule. Fonts are the standard case for the open rule from earlier: GET from "*", because you want any page of yours to be able to load it.

S3 Object Lambda access points

If you read through an S3 Object Lambda access point, S3 Object Lambda adds an "AllowedOrigins":"*" header field whenever the request comes from a browser or carries an Origin header. You don't write a bucket rule for it. The bucket's own rules still govern direct reads of the bucket.

What this costs, and the bill that actually hurts

CORS itself isn't a priced feature. What S3 charges for is storage, requests and data retrieval, data transfer, management and insights features, replication, and transform and query features. So the rule is free to add. What you pay for is the requests the rule lets through. All prices below are for US East (N. Virginia), S3 Standard, as of September 2026.

Here's a worked example with Jake's numbers. His trade-in gallery shows 40 product photos per page view and gets 25,000 page views a month.

Line Quantity Rate Cost
Photo GET requests 25,000 views × 40 photos = 1,000,000 $0.0004 per 1,000 1,000 × $0.0004 = $0.40
Adding or editing the CORS rule 1 change Not a priced feature $0.00
Jake's blank page on one Saturday 11 lost trade-ins about $35 margin each $385

The gallery's whole month of S3 requests costs less than a cup of coffee, and one blank Saturday cost almost a thousand times that. On accounts created since July 15, 2025, the free tier is a credits model, up to $200 in credits, and that would cover the $0.40 without a bill. Older accounts stayed on the 12-month model.

A few other pricing facts belong here. Browsing a bucket in the S3 console makes GET and LIST requests, and you're charged for them at the same rate as API calls. DELETE and CANCEL requests are free. Data transferred out of S3 to CloudFront is free, and so is the first 100 GB per month of data transferred out to the internet, added up across all AWS services and Regions. I can't point to a price line for preflight OPTIONS requests, so if you're running a lot of uploads, watch your first bill after a change instead of assuming.

Scope traps: per bucket, access policies, and other people's buckets

CORS is set per bucket. The configuration is stored as the cors subresource of one bucket, so each bucket carries its own. A new bucket starts with none, and a fix on prices does nothing for prices-final. Each bucket also holds up to 100 rules in its configuration, as of September 2026, which is plenty for a shop but worth knowing if you generate rules per customer.

Permissions still apply. A correct CORS rule on a bucket whose objects the caller can't read still fails, just with an access error instead of a CORS error. If you fix CORS and the error changes to a plain 403, that's progress: the CORS part passed and the permission part is next.

Other accounts' buckets. The CORS configuration lives on the bucket, so only whoever manages that bucket can change it. If the bucket belongs to another AWS account and its owner won't add your origin, you're not getting in from the browser side. The one legitimate workaround is to serve the files through your own CloudFront distribution and set a response headers policy there, and that only works if you're allowed to read the bucket in the first place.

Console works, CLI doesn't (or the reverse). Almost always the envelope from the table earlier: a bare list in the console, CORSRules in the CLI, CorsRules in CloudFormation.

One page works, another doesn't. Each page's origin is checked separately. https://www.jakesphones.example passing tells you nothing about https://book.jakesphones.example.

Automation: CloudFormation, a rule check, and a loop

If your buckets come from CloudFormation, the rule belongs in the template, or the next deploy quietly puts the old configuration back. Note the different property names from the table earlier:

{
  "Resources": {
    "PriceListBucket": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
        "CorsConfiguration": {
          "CorsRules": [
            {
              "AllowedOrigins": ["https://www.jakesphones.example"],
              "AllowedMethods": ["GET", "HEAD"],
              "AllowedHeaders": ["*"],
              "MaxAge": 3000
            }
          ]
        }
      }
    }
  }
}

For a quick check of every origin your site can appear under, loop over them and look for the header:

for origin in https://www.jakesphones.example http://www.jakesphones.example https://jakesphones.example; do
  echo "== $origin"
  curl -si -H "Origin: $origin" https://biscuit-price-lists.s3.amazonaws.com/price-list.json | grep -i "access-control-allow-origin"
done

An origin that prints nothing has no matching rule. The same idea works for auditing buckets, by looping aws s3api get-bucket-cors --bucket NAME over the bucket names you own and reading what each one returns. A bucket with no configuration comes back as an error or as nothing, not as a list of rules.

When nothing works: what to gather before you ask for help

First, the isolating trick. Temporarily set AllowedOrigins to "*" and AllowedMethods to ["GET"], then reload. If the error disappears, your problem is the origin, the method, or a header, and the rule was fine everywhere else. If it doesn't, the rule's match isn't the problem. Look at CloudFront, at the address you're calling, and at plain permissions. Put the narrow rule back as soon as you've learned which it is.

If you still need help from a teammate, the bucket owner, or AWS Support, collect these first. Each one turns "CORS doesn't work" into a question somebody can answer:

  • The exact page address, and the Origin value from the failing request's headers.
  • The full response headers of the failing request, including x-amz-request-id and x-amz-id-2. In an XML error body, those appear as RequestId and HostId.
  • The output of aws s3api get-bucket-cors --bucket biscuit-price-lists.
  • Whether the request goes to an S3 address or to CloudFront (or your own domain), and the distribution ID if it's CloudFront.
  • The result of both curl commands, the simple one and the preflight, using that exact origin.
  • Which account owns the bucket, and the time of a failing request.

Here's what I can't promise. If the bucket belongs to someone else and they won't change it, no JSON on your side fixes it. And nobody can change how a browser enforces CORS; the fix is always a header that comes back from S3 or CloudFront. Also, copy the bucket name out of the request instead of retyping it. Jake's cat-named bucket looks charming until you have to spell it into a support form at 9 a.m.

Frequently asked questions about S3 CORS errors

Why do I get No 'Access-Control-Allow-Origin' header is present on the requested resource from S3?

Your browser sent a request with an Origin header, and S3 had no CORS rule matching it, so the response carried no CORS headers and the browser blocked your page from reading the file. Either the bucket has no CORS configuration, or the origin, method, or headers in your request don't match any rule. Copy the Origin from the failing request, add it to AllowedOrigins, and save. If the files come through CloudFront, check that too.

What is the exact JSON that fixes an S3 CORS error?

For a site that only reads files, paste this into the console editor, with your own origin: [{"AllowedOrigins":["https://www.example.com"],"AllowedMethods":["GET","HEAD"],"AllowedHeaders":["*"],"MaxAgeSeconds":3000}]. In the CLI, wrap the same list as {"CORSRules":[ ... ]}. If the page uploads files, add PUT or POST to the methods and list any headers your code reads in ExposeHeaders.

Where do I edit the CORS configuration in the S3 console?

Open the S3 console, choose General purpose buckets, pick the bucket, and choose the Permissions tab. In the Cross-origin resource sharing (CORS) section choose Edit, paste or edit the JSON in the CORS configuration editor, and choose Save changes. The console accepts JSON only, and the text has to be valid JSON before it will save.

How do I set S3 CORS with aws s3api put-bucket-cors?

Run aws s3api put-bucket-cors --bucket biscuit-price-lists --cors-configuration '{"CORSRules":[{"AllowedOrigins":["https://www.example.com"],"AllowedMethods":["GET","HEAD"],"AllowedHeaders":["*"]}]}'. The list must sit inside a CORSRules key. Treat the command as a full replacement and include every rule you want to keep. Read the result back with aws s3api get-bucket-cors --bucket biscuit-price-lists.

Why is S3 CORS still not working after I added the rule?

Go in this order. First, make sure the Origin header matches your entry in scheme, host, and port. Second, check that the method and every requested header are allowed. Third, confirm you edited the bucket named in the request address. Fourth, if CloudFront is in front, forward the CORS headers, allow OPTIONS, include Origin in the cache key, and invalidate the cache. Fifth, remember a browser can keep a preflight answer for MaxAgeSeconds.

What does CORS Response: This CORS request is not allowed mean?

The bucket has a CORS configuration, but no rule matches your request. The cause is one of three things: the origin isn't in AllowedOrigins, the method isn't in AllowedMethods, or a header the preflight asked about isn't in AllowedHeaders. The XML body shows the Method and ResourceType that failed. Compare them with the request's Origin, Access-Control-Request-Method, and Access-Control-Request-Headers.

What does CORS is not enabled for this bucket mean?

The bucket has no CORS configuration at all, so S3 returns 403 Forbidden for a cross-origin request. Add a configuration through the Permissions tab or with put-bucket-cors. Once a configuration exists, a failed request shows This CORS request is not allowed instead, which means a rule exists but doesn't match.

Is it safe to use * as AllowedOrigins on an S3 bucket?

A lone * lets every origin send cross-origin requests to the bucket. CORS doesn't replace ACLs and access policies, which keep applying, so it doesn't open private files. My advice: use * only for GET on public assets such as web fonts. For anything that uploads, list your exact origins.

How do I allow multiple origins or a wildcard subdomain in S3 CORS?

List each origin as its own entry in AllowedOrigins. Or use one wildcard inside an entry, like https://*.example.com. An entry can hold only one *. List the bare domain (https://example.com) as its own entry to be sure it's covered.

Does http vs https matter in the S3 AllowedOrigins list?

Yes. The scheme, host, and port in the browser's Origin header must match an entry. A rule for http://www.example.com doesn't match https://www.example.com or http://www.example.com:80. Add each variant your site can appear under. An S3 website endpoint serves HTTP only, so an HTTPS site needs the https entry.

How do I allow localhost in S3 CORS?

Add the full origin including the port, such as http://localhost:3000. Every port is a different origin, so http://localhost:3001 needs its own entry. I'd keep development entries on a separate development bucket, so a leftover localhost rule never sits on the bucket your live site uses.

Why does the S3 CORS response show up in curl but not in my browser?

S3 only sends CORS headers when the request carries an Origin header. A curl call with no -H "Origin: ..." shows none, and one with the wrong origin shows none either. If curl works with the origin you typed and the browser still fails, the browser is sending a different origin (http versus https, www versus no www, or a port), or a cached or proxied response is in the way.

How do I fix S3 CORS errors when the files are served through CloudFront?

In the distribution's behavior, use the CORS-S3Origin origin request policy so Origin, Access-Control-Request-Headers, and Access-Control-Request-Method reach S3. Allow GET, HEAD, OPTIONS, keep Origin in the cache key, save, and invalidate the cache. Or set a response headers policy with Configure CORS so CloudFront sends the headers itself.

How do I allow S3 presigned URL uploads with CORS?

Add PUT (or POST) to AllowedMethods and put your site's exact origin in AllowedOrigins. Every header your upload sends has to match AllowedHeaders, and "*" is the simplest choice. If your code needs the ETag from the response, list it in ExposeHeaders.

How do I let JavaScript read the ETag or custom metadata headers from S3?

Name each header in ExposeHeaders, for example "ETag" or "x-amz-meta-custom-header". Without that, the request still returns 200 OK but your script can't see the header. List every header your code reads, one by one, and save the configuration again.

How do I view or delete the CORS configuration on a bucket, and how many rules can it hold?

View it in the Permissions tab or with aws s3api get-bucket-cors --bucket biscuit-price-lists. Delete it with aws s3api delete-bucket-cors --bucket biscuit-price-lists, which removes every rule. As of September 2026, a configuration can hold up to 100 rules.

Console labels move. If the Permissions tab looks different from what I describe, tell me, and I'll fix the page rather than have you doubt your own eyes. And if your page has been blank since the last deploy, the fix can be as small as one character. The next Saturday, Jake's first move was to open the Network tab and read the Origin line before he touched anything else.

📌 If you keep one line from this page

S3 answers a CORS question by matching your request against your rules character for character, so copy the Origin from the failing request instead of typing it from memory.

If the bucket answers correctly and the browser still fails, look at what sits in front of it.

Revision note. Written September 30, 2026. The 100-rule limit and the $0.0004 per 1,000 GET price are the two figures most likely to change, then the console labels under Permissions. The matching rule itself, origin plus method plus every requested header, is the same one S3 has always used, so the JSON on this page should keep working long after the labels move.

Related