Fixing CORS Preflight Errors on S3 Uploads

If your browser upload to S3 dies with a red CORS error and no HTTP status, the fix is almost always a missing or mismatched rule in the bucket CORS configuration — and each distinct console error maps to one specific rule.

This article lists the three errors you will actually hit, the exact text the browser prints, and the precise CORS change that resolves each. It builds on the parent guide to CORS configuration for uploads and the broader Backend Validation & Cloud Storage Architecture section.

When to use this approach

  • Your direct-to-S3 PUT or POST upload fails only in the browser, never in curl or Postman.
  • The browser Network tab shows a failed OPTIONS request before the upload.
  • A multipart upload completes per-part but fails to finalize.

Prerequisites

  1. Permission to call PutBucketCors on the target bucket.
  2. The exact origin your app serves from, including scheme and port.
  3. curl to replay the preflight without browser caching.

Map each error to its rule

CORS error to fix mapping Three common S3 CORS errors each map to a single field in the bucket CORS rule that resolves them. No Allow-Origin header AllowedOrigins (exact) Preflight 403 Forbidden AllowedMethods (PUT/POST) ETag is null on 200 ExposeHeaders: ETag
Each browser CORS error maps to one specific field in the S3 bucket CORS rule.

The corrected CORS configuration

Almost every preflight failure is fixed by one complete, correct rule. Apply this, then read below for which line addresses your specific error.

import { S3Client, PutBucketCorsCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function fixUploadCors(bucket: string): Promise<void> {
  await s3.send(
    new PutBucketCorsCommand({
      Bucket: bucket,
      CORSConfiguration: {
        CORSRules: [
          {
            // Fixes "No Access-Control-Allow-Origin header": must match exactly.
            AllowedOrigins: ["https://app.example.com", "http://localhost:5173"],
            // Fixes preflight 403: PUT/POST must be listed for uploads.
            AllowedMethods: ["PUT", "POST", "GET", "HEAD"],
            // Fixes "Request header field content-type is not allowed".
            AllowedHeaders: ["*"],
            // Fixes null ETag on multipart completion.
            ExposeHeaders: ["ETag", "x-amz-request-id"],
            MaxAgeSeconds: 3000,
          },
        ],
      },
    }),
  );
  console.log("CORS rule written");
  // Expected: "CORS rule written" — re-test in a fresh incognito window.
}

Critical parameter notes

  • AllowedOrigins is matched character-for-character. https://app.example.com does not cover https://www.app.example.com, nor http://app.example.com. List each real origin.
  • AllowedMethods controls the Access-Control-Allow-Methods response. If PUT is absent, the preflight for a PUT upload returns a 403 and the browser blocks the upload.
  • AllowedHeaders: ["*"] makes S3 reflect whatever the browser names in Access-Control-Request-Headers. Without it, sending Content-Type: image/png trips a header-not-allowed error.
  • ExposeHeaders must include ETag for any chunked upload, or response.headers.get("ETag") returns null.

Error 1: “No ‘Access-Control-Allow-Origin’ header is present”

The browser console prints:

Access to fetch at 'https://my-bucket.s3.amazonaws.com/uploads/x.png'
from origin 'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Cause. Either the bucket has no CORS configuration at all, or no rule lists your origin. S3 only emits Access-Control-Allow-Origin when an AllowedOrigins entry matches the request’s Origin header exactly.

Fix. Add your exact origin to AllowedOrigins as shown above. The most common trap is a scheme or port mismatch: http://localhost:5173 (dev server) versus http://localhost:3000 (your app guess). Copy the value straight from window.location.origin in the failing tab.

Error 2: Preflight 403 Forbidden

The Network tab shows the OPTIONS request itself returning 403, with this S3 body:

<Error>
  <Code>AccessForbidden</Code>
  <Message>CORSResponse: This CORS request is not allowed.</Message>
  <Method>PUT</Method>
</Error>

Cause. S3 received the preflight but no rule permits the combination of origin and method. The <Method>PUT</Method> line tells you the verb the browser asked for is not in any matching rule’s AllowedMethods.

Fix. Ensure AllowedMethods includes the verb from the error. For a presigned PUT you need "PUT"; for a presigned POST form you need "POST". Note this is distinct from a signature 403 (SignatureDoesNotMatch), which comes back on the real request, not the OPTIONS — if your 403 is on the OPTIONS, it is CORS, not signing. The signing side is covered in generating secure presigned URLs.

Error 3: Missing ExposeHeader ETag

The upload returns 200, yet your code throws when reading the ETag:

TypeError: Cannot read properties of null (reading 'replace')
// from: const tag = response.headers.get("ETag").replace(/"/g, "")

Cause. CORS hides all but a small safelist of response headers from JavaScript. ETag is not on that list, so headers.get("ETag") is null even though S3 sent it. Multipart completion needs each part’s ETag, so this silently breaks finalization.

Fix. Add ETag to ExposeHeaders. After applying, confirm the browser can see it; the request side of reading that header is the same fetch pattern used in the modern fetch API for uploads.

Configuration gotchas

The fix “didn’t work” because the preflight is cached

MaxAgeSeconds lets the browser reuse a prior preflight answer. After editing CORS, the old (failing) answer can persist. Always re-test in a fresh incognito window or via curl, which never caches.

You edited the wrong bucket or region

A bucket name reused across regions, or an S3Client pointed at the wrong region, means your PutBucketCors lands somewhere the upload never touches. Confirm the bucket host in the failing request matches the bucket you edited.

Verification

Replay the exact preflight and read the headers directly:

curl -i -X OPTIONS "https://my-bucket.s3.amazonaws.com/uploads/test.png" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type"

A healthy response contains Access-Control-Allow-Origin: https://app.example.com and Access-Control-Allow-Methods: PUT. If those lines are absent, your rule still does not match — recheck origin spelling and method.

FAQ

My OPTIONS request works in curl but the browser still fails. Why?

The browser is serving a cached preflight from before your fix, or it is sending an extra header (like a custom x-amz-meta-*) that your AllowedHeaders does not cover. Inspect Access-Control-Request-Headers on the browser’s actual OPTIONS and make sure every listed header is allowed.

Is a preflight 403 the same as SignatureDoesNotMatch?

No. A preflight 403 is on the OPTIONS request and is purely a CORS rule problem. SignatureDoesNotMatch is a 403 on the real PUT and means the signed headers do not match what the browser sent.

Do I need to expose ETag for a single-shot PUT?

Only if your code reads it. Single-part uploads often ignore the ETag, but multipart finalization requires it, so exposing ETag is the safe default.