How to Set CORS Headers on an Amazon S3 Bucket with AWS CLI

Set CORS headers on an Amazon S3 bucket: write the CORS policy, apply it with the AWS CLI, Console or Terraform, test the preflight with curl, fix CloudFront.

Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources. CORS also relies on a mechanism by which browsers make a “preflight” request to the server hosting the cross-origin resource, in order to check that the server will permit the actual request. In that preflight, the browser sends headers that indicate the HTTP method and headers that will be used in the actual request.

The CORS standard describes new HTTP headers which provide browsers a way to request remote URLs only when they have permission. Although some validation and authorization can be performed by the server, it is generally the browser’s responsibility to support these headers and honor the restrictions they impose.

An example of a cross-origin request: the frontend JavaScript code for a web application served from https://websiteone.com uses fetch() to make a request for https://websitetwo.io/data.json. Another example might be JavaScript that calls files in an object storage bucket, like web fonts, downloads or images. You can configure CORS for each bucket with the AWS CLI, the S3 console, or infrastructure as code.

Configuring CORS can be a challenge. In this post, we will learn how to open up your Amazon S3 bucket using CORS, how to check that it works, and how to fix the usual reasons it doesn’t.

This post is targeted at static websites or files hosted on an Amazon S3 bucket. I’m assuming you want to open up your bucket, for instance because you’re storing images that you wish to load into a canvas element, you’re storing a JSON file that you want to fetch client-side, or your users upload files straight to S3 from the browser with presigned URLs.

Of course you can adjust the settings to allow for more granular access.

Related content:

What CORS on S3 Does (and Doesn’t) Do

Two things are worth getting straight before writing any rules:

  • CORS is not access control. It tells the browser whether JavaScript on another origin may read the response. It does not make a private object public. The object still needs to be readable by the requester, through a bucket policy, a presigned URL, or CloudFront Origin Access Control. A curl from the terminal ignores CORS completely.
  • S3 only sends CORS headers when the request carries an Origin header that matches a rule. A plain request without Origin gets no Access-Control-* headers back, which is normal and is the root of most CloudFront caching problems (see below).

When a request arrives, S3 evaluates the bucket’s rules in order and uses the first rule that matches the origin, the method and (for a preflight) the requested headers. A bucket can hold up to 100 rules.

Amazon S3 CORS Settings

Let us create a policy config:

{
  "CORSRules": [
    {
      "ID": "allow-citizix-frontends",
      "AllowedOrigins": ["https://test.citizix.com", "https://data.citizix.com"],
      "AllowedHeaders": ["*"],
      "AllowedMethods": ["GET", "HEAD", "POST", "PUT", "DELETE"],
      "MaxAgeSeconds": 3000,
      "ExposeHeaders": ["ETag"]
    }
  ]
}

What each field does:

FieldPurpose
IDOptional label for the rule, handy when you have several.
AllowedOriginsOrigins allowed to make the request, scheme included (https://app.example.com). Each origin may contain one * wildcard, e.g. https://*.example.com.
AllowedMethodsAny of GET, PUT, POST, DELETE, HEAD. Nothing else is accepted.
AllowedHeadersRequest headers the browser may send, checked against Access-Control-Request-Headers in the preflight. * allows any.
ExposeHeadersResponse headers that JavaScript is allowed to read. Expose ETag if you do multipart uploads from the browser; the SDK needs it to complete the upload.
MaxAgeSecondsHow long the browser may cache the preflight response, so it doesn’t send an OPTIONS before every request.

Note that this opens up the bucket to access from the listed domains – https://test.citizix.com and https://data.citizix.com. If you want to open it completely, with no restrictions on origin, use:

"AllowedOrigins": ["*"]

This means any website will be able to read your data from a visitor’s browser. You have to decide whether this is appropriate for your use case! For public assets such as fonts or images a wildcard is fine. For anything that accepts writes (PUT, POST, DELETE), list your origins explicitly.

A more realistic setup splits read and write access into separate rules: anyone can read, only your app can upload.

{
  "CORSRules": [
    {
      "ID": "uploads-from-app",
      "AllowedOrigins": ["https://app.citizix.com"],
      "AllowedMethods": ["PUT", "POST", "DELETE"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3000
    },
    {
      "ID": "public-read",
      "AllowedOrigins": ["*"],
      "AllowedMethods": ["GET", "HEAD"],
      "AllowedHeaders": ["*"],
      "MaxAgeSeconds": 3000
    }
  ]
}

Applying the Policy with the AWS CLI

The easiest way to update your CORS policy at Amazon is using the AWS CLI tool.

In order to do so, store the above snippet in a JSON file, s3-cors.json, and run the following:

aws s3api put-bucket-cors \
    --profile my-aws-profile \
    --bucket my-bucket \
    --cors-configuration file://s3-cors.json

The command prints nothing on success. Note that put-bucket-cors replaces the whole configuration; to add a rule, fetch the current one, edit it and put it back.

Applying the Policy from the AWS Console

Alternatively, you can do this from the AWS Console. Log into your account, find your bucket, and open the Permissions tab. Scroll to Cross-origin resource sharing (CORS), click Edit, and paste your rules.

The console expects only the array of rules, without the CORSRules wrapper the CLI uses:

[
  {
    "AllowedOrigins": ["https://test.citizix.com", "https://data.citizix.com"],
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "HEAD", "POST", "PUT", "DELETE"],
    "MaxAgeSeconds": 3000,
    "ExposeHeaders": ["ETag"]
  }
]

Applying the Policy with Terraform or OpenTofu

If the bucket is managed as code, use the aws_s3_bucket_cors_configuration resource. Since version 4 of the AWS provider, CORS lives in its own resource; the inline cors_rule block on aws_s3_bucket is deprecated.

resource "aws_s3_bucket" "assets" {
  bucket = "my-bucket"
}

resource "aws_s3_bucket_cors_configuration" "assets" {
  bucket = aws_s3_bucket.assets.id

  cors_rule {
    id              = "uploads-from-app"
    allowed_origins = ["https://app.citizix.com"]
    allowed_methods = ["PUT", "POST", "DELETE"]
    allowed_headers = ["*"]
    expose_headers  = ["ETag"]
    max_age_seconds = 3000
  }

  cors_rule {
    id              = "public-read"
    allowed_origins = ["*"]
    allowed_methods = ["GET", "HEAD"]
  }
}

See the resource documentation for every argument.

Getting the CORS Configuration of a Bucket

To retrieve the CORS rules of a bucket, use the AWS CLI:

aws s3api get-bucket-cors --bucket BUCKETNAME

If CORS rules are set for the bucket, the API returns a JSON list like this example:

$ aws s3api get-bucket-cors --bucket my-bucket
{
    "CORSRules": [
        {
            "AllowedHeaders": [
                "*"
            ],
            "AllowedMethods": [
                "GET",
                "HEAD",
                "POST",
                "PUT",
                "DELETE"
            ],
            "AllowedOrigins": [
                "https://test.citizix.com",
                "https://data.citizix.com"
            ],
            "ExposeHeaders": [
                "ETag"
            ],
            "MaxAgeSeconds": 3000
        }
    ]
}

If there are no CORS rules set for the bucket, an error message appears:

An error occurred (NoSuchCORSConfiguration) when calling the GetBucketCors operation: The CORS configuration does not exist

Verifying the CORS Configuration of a Bucket

To verify the CORS rules of a bucket, use curl to send the same preflight a browser would: an OPTIONS request with an Origin and the method you intend to use.

For example:

curl -i -X OPTIONS \
  -H 'Origin: https://test.citizix.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: content-type' \
  https://BUCKETNAME.s3.eu-west-1.amazonaws.com/index.html

When a rule matches, S3 answers 200 OK with CORS headers along these lines (trimmed):

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://test.citizix.com
Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Expose-Headers: ETag
Access-Control-Max-Age: 3000
Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method

When nothing matches, you get 403 Forbidden with a CORSResponse error saying the CORS request is not allowed. Try an origin that isn’t in your list to confirm the rules are actually restricting access.

To check a simple GET, send the Origin header and look for Access-Control-Allow-Origin in the response:

curl -s -o /dev/null -D - \
  -H 'Origin: https://test.citizix.com' \
  https://BUCKETNAME.s3.eu-west-1.amazonaws.com/index.html | grep -i access-control

Deleting the CORS Configuration of a Bucket

To delete the CORS rules of a bucket, use the AWS CLI:

aws s3api delete-bucket-cors --bucket BUCKETNAME

CloudFront

One common configuration is to use CloudFront to serve your bucket contents behind a custom domain, using SSL.

Header forwarding in particular can cause quite a bit of head scratching, since it’s not always clear why your carefully constructed CORS rules are still not being picked up by the browser!

The cause is almost always caching. A request without an Origin header (an <img> tag, a crawler, curl) reaches S3, gets a response without CORS headers, and CloudFront caches it. The next fetch() from your site gets that cached copy and the browser blocks it.

To avoid that, CloudFront has to pass the CORS request headers through to S3 and keep separate cache entries per origin. In particular, for our example, the following headers are required:

  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers

On current distributions you do this with policies on the cache behavior rather than the legacy header whitelist:

  • Origin request policy: attach the managed CORS-S3Origin policy. It forwards exactly those three headers to S3.
  • Cache policy: the managed CachingOptimized policy does not include Origin in the cache key. If the same objects are requested both with and without an Origin header, create a custom cache policy that includes Origin (and, for preflights, the two Access-Control-Request-* headers) in the cache key.
  • Allowed methods: set the behavior to allow GET, HEAD, OPTIONS, and enable caching for OPTIONS so preflights don’t hit S3 every time.

Having set these, you’re all set up and your client-side code will finally be able to fetch() your content.

With the legacy cache settings (no policies), the equivalent is to whitelist the same three headers under Cache based on selected request headers. AWS recommends moving to policies.

CloudFront Response Headers Policies

Note that CloudFront also offers the option of attaching Response Headers Policies to a distribution. In that way you can configure CORS headers completely independent of the bucket, which might suit your use case better.

CloudFront ships managed policies for the common cases (full list):

Managed policyUse it when
SimpleCORSYou only need Access-Control-Allow-Origin: * on simple requests.
CORS-With-PreflightBrowsers send preflight OPTIONS requests and any origin is allowed.
CORS-and-SecurityHeadersPolicySame as SimpleCORS, plus HSTS, X-Content-Type-Options and friends.
CORS-with-preflight-and-SecurityHeadersPolicyPreflight support plus the security headers.

These managed policies allow every origin. To restrict origins, create a custom response headers policy with your own allowed origins, methods and headers. You can also set Origin override so CloudFront’s CORS headers replace whatever S3 returns.

Troubleshooting S3 CORS Errors

Symptom in the browser consoleLikely cause
No 'Access-Control-Allow-Origin' header is present on S3 directlyNo rule matches the origin. Check the scheme (https vs http), port and trailing slashes; https://app.example.com/ with a slash never matches.
Same error only through CloudFront, intermittentlyA response without CORS headers was cached. Forward Origin with CORS-S3Origin, add it to the cache key, then invalidate.
Preflight returns 403The method or a header in Access-Control-Request-Headers isn’t allowed. Custom headers like x-amz-meta-* or content-md5 must be listed or covered by *.
Upload works but multipart upload fails to completeETag is not in ExposeHeaders.
403 Access Denied (not a CORS error)Permissions problem, not CORS. Check the bucket policy, Block Public Access, or the presigned URL.
Old behavior after a fixThe browser cached the preflight for MaxAgeSeconds. Hard-reload or test in a private window.

For more detail, see the AWS CORS documentation for S3 and MDN’s guide to CORS.

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy