← Blog
Two cream paper window frames connected by a green strip with an orange tab on a lavender background.
Published on
2026-10-29

S3 CORS: configure browser uploads and fix preflight errors

S3 CORS (cross-origin resource sharing) lets browser JavaScript read responses from an Amazon S3 endpoint on another origin. To make it work, the bucket's CORS configuration must match the application's origin, requested method, and relevant request headers. The actual S3 operation must also be authorized. A successful preflight does not prove that an upload or download will succeed.

This guide covers Amazon S3 general purpose buckets, with examples for a private bucket and presigned browser uploads. Do not assume identical behavior from every S3-compatible service. Our Amazon cloud storage guide explains where S3 fits among AWS storage services.

When does an S3 request need CORS?

A page at https://app.example.com and an S3 API endpoint have different origins. An origin consists of the scheme, hostname, and port, without a path. Changing from HTTP to HTTPS or from port 3000 to 5173 changes the origin. Use the browser's actual Origin header when diagnosing a mismatch.

Browser requests made with fetch() follow CORS rules. Some requests, including PUT, trigger an OPTIONS preflight before the actual operation. A simple GET can skip preflight but still needs an appropriate CORS response for JavaScript to read it. Opening a URL in the address bar or making a server-side request is a different test. See the MDN CORS guide.

Keep access permissions separate. A bucket policy, IAM permissions, and a valid signature can authorize an S3 request; CORS tells the browser whether the cross-origin response may be exposed to the application. Enabling CORS does not make a private bucket public, and restricting origins does not stop non-browser clients with valid access. AWS confirms that access policies still apply.

A focused S3 CORS configuration

The following JSON is ready for the S3 console's CORS editor after you replace the example origin. It supports GET, HEAD, and PUT from one application. It allows the Content-Type request header and exposes the ETag response header to JavaScript.

[
  {
    "AllowedOrigins": [
      "https://app.example.com"
    ],
    "AllowedMethods": [
      "GET",
      "HEAD",
      "PUT"
    ],
    "AllowedHeaders": [
      "Content-Type"
    ],
    "ExposeHeaders": [
      "ETag"
    ],
    "MaxAgeSeconds": 300
  }
]

These settings serve different purposes:

  • AllowedOrigins identifies the calling application, such as https://app.example.com. Do not put the S3 endpoint, a page path, or a trailing slash here.
  • AllowedMethods lists the actual operations. S3 accepts GET, HEAD, PUT, POST, and DELETE here. OPTIONS is the preflight method, so do not add it to this list.
  • AllowedHeaders covers headers requested during preflight. Add the actual checksum, metadata, or other headers your upload client sends when needed; the example only covers Content-Type.
  • ExposeHeaders controls which additional response headers JavaScript can read. Allowing a request header does not expose a response header.
  • MaxAgeSeconds sets a preflight cache duration. It is not the expiry time of a presigned URL.

AWS documents the CORS configuration fields. Remove unused methods and headers rather than adding every possible value. For local development, use a separate explicit origin such as http://localhost:5173 in a test environment.

Save the rules without replacing other applications' settings

First inspect the existing configuration. This read-only command needs s3:GetBucketCORS. Replace the bucket and 12-digit owner account ID, and use the intended AWS profile and region.

aws s3api get-bucket-cors --bucket "amzn-s3-demo-cors-bucket" --expected-bucket-owner "123456789012" --output json

Keep a copy of the current rules and check which applications depend on them. In the S3 console, open the bucket, choose Permissions, then edit Cross-origin resource sharing (CORS). Merge the required changes into the existing rule list before saving. Follow the console configuration steps.

The console takes the JSON array shown above. The AWS CLI's --cors-configuration parameter takes an object containing that array under CORSRules. For a bucket with no other rules to preserve, save this complete example as cors-cli.json:

{
  "CORSRules": [
    {
      "AllowedOrigins": [
        "https://app.example.com"
      ],
      "AllowedMethods": [
        "GET",
        "HEAD",
        "PUT"
      ],
      "AllowedHeaders": [
        "Content-Type"
      ],
      "ExposeHeaders": [
        "ETag"
      ],
      "MaxAgeSeconds": 300
    }
  ]
}

The next command changes the bucket configuration. It replaces the complete CORS rule set, so include all rules you need to retain in that file before running it. It requires s3:PutBucketCORS.

aws s3api put-bucket-cors --bucket "amzn-s3-demo-cors-bucket" --expected-bucket-owner "123456789012" --cors-configuration file://cors-cli.json

Consult the PutBucketCORS reference for the request format. After saving, read the configuration again and compare it with the intended file. S3 uses the first rule matching the origin, method, and requested headers; review overlapping rules instead of assuming their settings combine.

Test the preflight separately from the upload

Use an object API endpoint in the bucket's region. Replace the example bucket, region, key, and origin. This command asks about a PUT request with Content-Type; it does not upload data. On Windows PowerShell, use curl.exe if curl resolves to a shell alias.

curl -i -X OPTIONS "https://amzn-s3-demo-cors-bucket.s3.us-east-1.amazonaws.com/uploads/example.txt" -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type"

Inspect the status and CORS response headers. Access-Control-Allow-Origin should match your app, Access-Control-Allow-Methods should include PUT, and Access-Control-Allow-Headers should cover Content-Type. Curl displays the response but does not enforce the browser's CORS rules. AWS's CORS testing instructions describe this preflight test.

Then test from the real application origin using the browser's Network panel. Check both OPTIONS, when present, and the actual PUT. A cached preflight may mean no new OPTIONS request appears. A successful OPTIONS response establishes neither authorization nor the success of the later write.

Use a presigned PUT URL from browser JavaScript

Your backend should authorize the user and choose the permitted object key before returning a short-lived upload URL. Pass the URL unchanged to the browser. If Content-Type was signed, send the exact same value, not a guessed value from the file extension. AWS explains these constraints in its presigned upload guide.

This function takes a presigned PUT URL, the selected File or Blob, and the content type agreed with the backend. Calling it uploads the supplied file to the signed destination; an existing object at that key may be replaced. Use a new key or a separately designed conditional-write flow when replacement must be prevented.

async function uploadWithPresignedUrl(url, file, contentType) {
  const response = await fetch(url, {
    method: "PUT",
    mode: "cors",
    credentials: "omit",
    redirect: "error",
    headers: { "Content-Type": contentType },
    body: file
  });
  if (!response.ok) {
    throw new Error(`Upload failed with HTTP ${response.status}`);
  }
  return { status: response.status, etag: response.headers.get("ETag") };
}

The example uses the URL's query signature and omits browser-managed credentials. It sends the file directly, not a FormData envelope. It rejects redirects so a wrong endpoint is investigated instead of silently followed. The backend and bucket must support the request independently of this function.

The returned ETag may be null if it is absent or not exposed. Do not log or publish the full presigned URL: it carries temporary access. If fetch fails after a write may have reached S3, verify the intended object through an authorized backend before retrying blindly.

Diagnose common S3 CORS errors

OPTIONS returns 403

Check the exact Origin, Access-Control-Request-Method, and every header listed in Access-Control-Request-Headers against one matching rule. An SDK may add headers that the minimal example does not allow. AWS distinguishes a missing CORS configuration from a request that does not match the rules.

OPTIONS succeeds, but PUT fails

Inspect the actual response and authorized server-side diagnostics. Check URL expiry, the signed method and headers, the bucket region, and the signer's write permissions. Making the CORS origin broader cannot repair an invalid signature or an access denial. Missing CORS headers on an error response may also hide its details from browser JavaScript.

The upload succeeds, but JavaScript cannot read ETag

Check ExposeHeaders and the actual response. The browser may show ETag in the Network panel while withholding it from JavaScript. Adding ETag to AllowedHeaders addresses a different direction and will not fix that response-reading problem.

Wildcard origins or no-cors appear to fix the error

A wildcard origin cannot be used for a credentialed CORS response; see MDN's credential guidance. For the query-signed example above, keep credentials: "omit". Other authentication designs need their own review.

Changing fetch to mode: "no-cors" is not a PUT-upload solution. That mode restricts methods and headers and prevents JavaScript from inspecting the response. Fix the server configuration and request instead; the Request mode reference explains the limitations.

A direct S3 test works, but the CDN path fails

For CloudFront or another proxy, inspect its OPTIONS handling, forwarded CORS request headers, response headers, and cache behavior. Different origins must not receive an incorrectly reused cached response. A direct bucket test does not validate the complete CDN or proxy path.

Keep the application test as the final check

Verify one intended origin and one unapproved origin, the actual upload headers, and the behavior after URL expiry. Confirm that private objects remain private through the authorization model. Record the working rules with the application configuration so a later frontend change does not silently break uploads.

For the wider design, including storage interfaces and access patterns, see our cloud storage for applications guide. CORS is one part of that integration; the completed browser upload and the server's access checks provide the evidence that it works.

Your next workload belongs on Hivenet.

Pick one AI, compute, or storage workload and see the difference for yourself. Spin it up in minutes, or let our team map your fastest path to production.