How to8 min readOctober 1, 2026

S3 CORS configuration explained: examples, preflight requests and fixing CORS errors

When a bucket needs a CORS configuration, how S3 matches a request against its rules, working examples for browser downloads and presigned uploads, and how to track down the usual "No Access-Control-Allow-Origin header" error.

JeVaughn Ferguson
Founder, developer
The short version

A bucket needs a CORS configuration only when JavaScript in a browser calls S3 from another origin. S3 uses the first rule whose origin, method and requested headers all match, so list exact origins without trailing slashes, add every header the page sends, and expose ETag for browser multipart uploads. When a CORS error appears, check the actual response first: an expired or mis-signed URL often looks like a CORS problem.

CORS, cross-origin resource sharing, is how a browser decides whether JavaScript on one site may read a response from another. When a web app at app.example.com uses fetch to download a file from S3, or uploads to S3 with a presigned URL, the browser asks S3 whether that origin is allowed. Without a CORS configuration on the bucket, the answer is no, and the request fails with a CORS error.

The configuration itself is short. Most of the trouble comes from not knowing when it is needed, how S3 picks a rule, and which errors are really CORS. This guide covers all three.

When a bucket needs CORS, and when it does not

CORS only applies to requests made by scripts in a browser to a different origin: fetch, XMLHttpRequest, and the AWS SDK for JavaScript running in a page. A scheme, host and port together make an origin, so https://app.example.com and https://example.com are different.

It is not needed for the AWS CLI, SDKs on a server, or anything else outside a browser. It is also not needed for a plain link, a download the browser navigates to, or an img tag showing a presigned URL. Those are not script reads. Canvas drawing and img or video tags with the crossorigin attribute are the exception: they do need it.

CORS is not access control either. It does not stop anyone with valid credentials or a presigned URL from calling S3 with curl. It only decides which web pages may read the responses in a browser. Who may read and write the objects is still the job of IAM, bucket policies and presigned URLs.

How S3 matches a request to a rule

A CORS configuration is a list of up to 100 rules. Each rule names the origins, methods and request headers it allows, and optionally which response headers scripts may read and how long a browser may cache the answer. S3 uses the first rule that matches all three of origin, method and requested headers, so put narrow rules before broad ones.

For anything beyond a simple GET, the browser first sends a preflight OPTIONS request naming the method and headers it wants to use. If no rule matches, S3 answers 403, the browser never sends the real request, and the page sees a CORS error.

  • AllowedOrigins: exact origins such as https://app.example.com, with no path and no trailing slash. Each entry may contain one * wildcard, as in https://*.example.com, and "*" alone allows every origin.
  • AllowedMethods: any of GET, PUT, POST, DELETE and HEAD.
  • AllowedHeaders: request headers the page may send, such as Content-Type or x-amz-*; a preflight fails if any requested header is not listed.
  • ExposeHeaders: response headers scripts may read. Browsers hide most headers by default, including ETag.
  • MaxAgeSeconds: how long the browser may cache the preflight answer.

Example: read-only downloads from one app

This is the common case: a single application reads objects from a private bucket using presigned GET URLs or signed SDK calls. It allows GET and HEAD from one origin, exposes the headers a viewer needs for range requests and file names, and caches the preflight for an hour.

The console takes the rules as a JSON array. The CLI and API take the same rules wrapped in a CORSRules object, and put-bucket-cors replaces the bucket's whole configuration, so always send the full set.

cat > cors.json <<'EOF'
{
  "CORSRules": [
    {
      "ID": "app-read",
      "AllowedOrigins": ["https://app.example.com"],
      "AllowedMethods": ["GET", "HEAD"],
      "AllowedHeaders": ["Range", "Authorization", "x-amz-*"],
      "ExposeHeaders": ["Content-Length", "Content-Range", "Content-Disposition", "ETag"],
      "MaxAgeSeconds": 3600
    }
  ]
}
EOF
aws s3api put-bucket-cors --bucket amzn-s3-demo-bucket --cors-configuration file://cors.json
aws s3api get-bucket-cors --bucket amzn-s3-demo-bucket

Example: uploads from the browser with presigned URLs

Direct browser uploads add PUT, or POST for presigned POST forms, and usually the Content-Type header, because the page sets it on the upload. If the URL was signed with a content type, the browser must send exactly that value or S3 rejects the signature.

Multipart uploads from the browser need one more thing: ExposeHeaders must include ETag. Completing a multipart upload requires the ETag of every part, and without it the script reads an empty header and the final request fails. List your production and development origins explicitly rather than using "*" for anything that writes.

cat > cors.json <<'EOF'
{
  "CORSRules": [
    {
      "ID": "app-upload",
      "AllowedOrigins": ["https://app.example.com", "http://localhost:3000"],
      "AllowedMethods": ["PUT", "POST"],
      "AllowedHeaders": ["Content-Type", "Content-MD5", "x-amz-*"],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3600
    },
    {
      "ID": "app-read",
      "AllowedOrigins": ["https://app.example.com", "http://localhost:3000"],
      "AllowedMethods": ["GET", "HEAD"],
      "AllowedHeaders": ["Range", "x-amz-*"],
      "ExposeHeaders": ["Content-Length", "Content-Range", "ETag"],
      "MaxAgeSeconds": 3600
    }
  ]
}
EOF
aws s3api put-bucket-cors --bucket amzn-s3-demo-bucket --cors-configuration file://cors.json

Behind CloudFront

When a CloudFront distribution sits in front of the bucket, CloudFront has to pass the Origin header and the preflight headers through to S3, and cache responses separately per origin. Otherwise the first origin's response, or a response without CORS headers, is cached and served to everyone.

The managed origin request policy CORS-S3Origin forwards the right headers. Pair it with a cache policy that includes the Origin header in the cache key, and allow OPTIONS in the behaviour's allowed methods so preflights reach S3.

Fixing "No Access-Control-Allow-Origin header"

The browser reports nearly every failed cross-origin request the same way, so start in the Network tab, not the configuration. Look at the preflight OPTIONS request and the real request, their status codes and the response body.

  • No CORS configuration at all: get-bucket-cors returns NoSuchCORSConfiguration.
  • The origin does not match: a trailing slash, http instead of https, a different port or a missing subdomain.
  • A requested header is not in AllowedHeaders: the preflight lists it in Access-Control-Request-Headers; add it or use x-amz-* for the SDK's headers.
  • The real problem is not CORS: an expired presigned URL, a signature mismatch or a missing permission returns 403, and the browser may report it as a CORS failure. Read the XML error body before changing rules.
  • The browser cached an old answer: a preflight is cached for MaxAgeSeconds, so retest in a private window after changing the configuration.
  • A rule earlier in the list matched first with fewer exposed headers: S3 uses the first matching rule, not the most generous one.
Try it in BucketDesk

Starter is free. Deploy a scoped role with CloudFormation, sign in, and browse, without handing anyone an access key.

Connect a bucket

Primary sources

Discussion

0 comments · open to guests · moderated
Comments appear after a quick review.

Liked this? Get the next article by email. No schedule, no filler, one click to leave.

Keep reading

All writing →