Fixing CORS Preflight Errors on S3 Uploads

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

This article is the error catalogue. It gives the text the browser actually prints, the evidence that tells you which layer failed, and the precise change that resolves each case. The mechanism underneath — the safelist, rule matching order, the preflight cache — belongs to the parent guide on CORS configuration for uploads, inside Backend Validation & Cloud Storage Architecture. Read this one when something is already broken and you want it working in the next ten minutes.

When to use this approach

  • The upload fails only in a browser tab. The same signed URL succeeds from curl, Postman or a Node script, which rules out the signature, the bucket policy and the object key.
  • DevTools shows either a failed OPTIONS row before the upload, or a fetch() that rejects with TypeError: Failed to fetch carrying no status code and no response object.
  • A chunked upload transfers every part with a 200 and then fails at CompleteMultipartUpload.

If uploads instead flow through your own API and you re-upload server-side, none of this applies — CORS is enforced by the browser, and server-to-server requests never preflight. That is one of the quieter arguments in presigned URL versus server proxy trade-offs.

Prerequisites

  1. Node 20+ with @aws-sdk/client-s3 3.700 or later for the scripts below.
  2. AWS_REGION set to the bucket’s real region, and credentials whose principal is allowed to write the CORS document:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutBucketCORS", "s3:GetBucketCORS"],
      "Resource": "arn:aws:s3:::media-uploads"
    }
  ]
}
  1. The exact failing origin, copied from window.location.origin in the broken tab rather than typed from memory.
  2. curl 7.75+, so you can replay a preflight with no browser cache in the way.

Triage: work out which layer actually failed

Before changing anything, open DevTools, switch the Network tab’s filter to All (an OPTIONS row is hidden under both Fetch/XHR and Doc), and reproduce. Four evidence signatures cover essentially every case, and they point at different subsystems.

Triage tree for a failing browser upload to S3 Four network-tab signatures — no OPTIONS row, OPTIONS 403, OPTIONS 200 with a PUT 403, and a 200 PUT with a null ETag — each lead to a different root cause. Upload fails in the browser but the same URL works from curl No OPTIONS row in the Network tab OPTIONS is 403 AccessForbidden OPTIONS is 200 then PUT is 403 PUT is 200 ETag reads as null No preflight ran simple POST form, or the wrong host No rule matched origin, method or header is missing CORS is correct signature or bucket policy rejected it Header stripped ExposeHeaders has no ETag entry Read the OPTIONS row first: it separates a CORS fault from a signing fault
The status of the OPTIONS row, not the console text, is what tells you whether to edit CORS or the signature.

Two of those four columns are not CORS problems at all, which is why the triage step pays for itself. A 403 on the real PUT after a green preflight is a signing problem — go to generating secure presigned URLs with AWS SDK v3. A missing OPTIONS row usually means the request was simple enough to skip the preflight (a presigned POST form posts multipart/form-data, which is safelisted) or that the host in the URL never resolved.

Implementation: the CORS document that fixes all four

PutBucketCors replaces the entire document — there is no merge, no append. If the bucket also serves public reads, its GET/HEAD rule has to be in the same array or you will fix uploads and break playback in the same call. This script writes both rules, then reads the live configuration back and asserts on it, because PutBucketCors is eventually consistent and a naive retest can hit the old document.

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

const s3 = new S3Client({ region: process.env.AWS_REGION ?? "eu-west-1" });

// Order matters: S3 stops at the first rule whose origin, method and
// requested headers all match, so the narrow upload rule goes first.
const uploadRule: CORSRule = {
  ID: "browser-direct-upload",
  AllowedOrigins: ["https://app.example.com", "http://localhost:5173"],
  AllowedMethods: ["PUT", "POST"],
  AllowedHeaders: [
    "content-type",
    "content-md5",
    "x-amz-checksum-crc32",
    "x-amz-sdk-checksum-algorithm",
    "x-amz-meta-*",
  ],
  ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"],
  MaxAgeSeconds: 3000,
};

const readRule: CORSRule = {
  ID: "public-playback",
  AllowedOrigins: ["*"],
  AllowedMethods: ["GET", "HEAD"],
  AllowedHeaders: ["range"],
  ExposeHeaders: ["Content-Range", "Content-Length", "ETag"],
  MaxAgeSeconds: 86400,
};

export async function applyUploadCors(bucket: string): Promise<void> {
  await s3.send(
    new PutBucketCorsCommand({
      Bucket: bucket,
      CORSConfiguration: { CORSRules: [uploadRule, readRule] },
    }),
  );

  const live = await s3.send(new GetBucketCorsCommand({ Bucket: bucket }));
  const applied = live.CORSRules?.find((rule) => rule.ID === uploadRule.ID);
  if (!applied) {
    throw new Error(`rule ${uploadRule.ID} is absent from ${bucket}`);
  }
  if (!applied.ExposeHeaders?.includes("ETag")) {
    throw new Error("ETag is not exposed — multipart completion will fail");
  }
  console.log(
    `ok: ${bucket} allows ${applied.AllowedMethods?.join("/")} from ` +
      `${applied.AllowedOrigins?.join(", ")}`,
  );
}

Running it against a healthy bucket prints one line:

ok: media-uploads allows PUT/POST from https://app.example.com, http://localhost:5173

Critical parameter notes

  • AllowedOrigins is compared as a string after normalisation, so https://app.example.com covers neither https://www.app.example.com nor http://app.example.com nor https://app.example.com:8443. S3 permits a single * character per entry, which makes https://*.example.com legal and https://*.*.example.com a validation error.
  • AllowedMethods accepts only GET, PUT, HEAD, POST and DELETE. Adding OPTIONS, as Azure’s documentation tells you to, fails the API call outright.
  • AllowedHeaders is listed by name here rather than as ["*"]. The wildcard works and is the fastest way out of an incident, but it also means you never find out which headers your client actually sends, so the list silently stops matching reality. Naming them turns a future mismatch into a preflight failure you can read.
  • ExposeHeaders takes no wildcard on S3. ETag must be spelled out; x-amz-request-id and x-amz-id-2 are the two values AWS Support asks for when you open a ticket, so expose them while you are there.
  • MaxAgeSeconds: 3000 sits under Chromium’s 7200-second clamp and above Safari’s 600-second one, so every engine honours the value verbatim.

The four errors, by exact text

Mapping each S3 CORS error string to the field that fixes it Four browser or S3 error messages, the network evidence that accompanies each, and the single CORS field — AllowedOrigins, AllowedMethods, AllowedHeaders or ExposeHeaders — that resolves it. Each error string names the one field that fixes it No 'Access-Control-Allow-Origin' header is present on the requested resource on the upload itself — no preflight ran AllowedOrigins exact scheme, host and port 403 AccessForbidden — CORSResponse: This CORS request is not allowed the OPTIONS row itself is red AllowedMethods must list PUT or POST Request header field x-amz-checksum- crc32 is not allowed by Allow-Headers OPTIONS is 200; the PUT never leaves AllowedHeaders name every non-safelisted header TypeError: Cannot read properties of null (reading 'replace') the PUT returned 200 and stored the part ExposeHeaders add ETag; no wildcard accepted
Match the string in your console to the left column and change only the field on the right.

Error 1: no Access-Control-Allow-Origin header

Access to fetch at 'https://media-uploads.s3.eu-west-1.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. The response arrived — often with a 200 — but carried no allow header, so the browser discarded it. On S3 this means no rule matched the Origin. Because it appears on the real request rather than on an OPTIONS, it is the signature of a simple request: a presigned POST form, a GET of an uploaded object, or a PUT whose only header is a safelisted Content-Type such as text/plain.

Fix. Add the exact origin to AllowedOrigins. The two traps are the port (http://localhost:5173 for a Vite dev server, not :3000) and the www. prefix on production. If the bucket has no CORS document at all, S3 says CORSResponse: CORS is not enabled for this bucket. instead — a different string worth grepping for.

Error 2: the preflight returns 403

The OPTIONS row is red and its response body is S3’s XML:

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

Chromium’s console framing for the same event is Response to preflight request doesn't pass access control check: It does not have HTTP ok status. That wording — HTTP ok status — is the tell that the OPTIONS itself failed, not the upload.

Cause. S3 evaluated its rules and none matched the combination of origin, method and requested headers. The <Method> element echoes the verb from Access-Control-Request-Method, which narrows it fast: if it says PUT and your rule lists only GET, HEAD, POST, you have found it.

Fix. Put the verb in AllowedMethods. Note that a preflight 403 is never a signing problem — the OPTIONS request carries no Authorization header and no query signature, so SignatureDoesNotMatch cannot occur on it. If you see a 403 on the PUT instead, that is the third triage column, and the signature is what needs attention.

Error 3: a request header is not allowed

Header-by-header negotiation in a preflight The browser asks with Origin, Access-Control-Request-Method and Access-Control-Request-Headers; S3 answers each one, and the unanswered checksum header is what blocks the upload. What the browser asks What the bucket answers Origin https://app.example.com Access-Control-Request-Method PUT Access-Control-Request-Headers content-type, x-amz-checksum-crc32 Access-Control-Allow-Origin https://app.example.com Access-Control-Allow-Methods PUT, POST Access-Control-Allow-Headers content-type checksum header absent unanswered Request header field x-amz-checksum-crc32 is not allowed by Access-Control-Allow-Headers in preflight response
Every name in Access-Control-Request-Headers must come back in the answer; one missing name blocks the whole upload.

Cause. The browser lists every non-safelisted header the real request will send, lowercased and alphabetically sorted, and refuses to proceed unless the answer covers all of them. The modern version of this bug is not a custom header you wrote — it is one the SDK added. Since the flexible-checksums change in @aws-sdk/client-s3 3.729, PutObjectCommand defaults requestChecksumCalculation to WHEN_SUPPORTED, so a URL signed by getSignedUrl can require x-amz-checksum-crc32 and x-amz-sdk-checksum-algorithm. Your client code never mentions them, your CORS rule never allowed them, and the upload that worked last quarter stops working after a dependency bump.

Fix. Either allow the headers, as the implementation above does, or stop the SDK from signing them by constructing the client with requestChecksumCalculation: "WHEN_REQUIRED". Choose one and record which — allowing them keeps the integrity check, disabling them keeps the CORS document small. Whatever the header is, read it off the browser’s own OPTIONS request rather than guessing; the Access-Control-Request-Headers line is the authoritative list of what your client sends, and it is the same list you should be asserting against in retrying fetch uploads with idempotency keys when a retry adds a header the first attempt did not have.

Error 4: ETag reads as null and multipart completion fails

TypeError: Cannot read properties of null (reading 'replace')
// from: const tag = response.headers.get("ETag").replace(/"/g, "")
How a hidden ETag turns a successful part upload into InvalidPart S3 returns 200 with an ETag, the browser CORS filter strips it because ExposeHeaders omits it, and CompleteMultipartUpload then fails with 400 InvalidPart. The 200 that still loses your part S3 response on the wire HTTP/1.1 200 OK ETag: "9b2cf5a1" x-amz-request-id: 8FE2 Content-Length: 0 CORS response filter 7-header safelist plus ExposeHeaders applied by the browser, never by S3 Readable by script Content-Type, Content-Length Stripped to null ETag, x-amz-request-id CompleteMultipartUpload PartNumber: 1 ETag: (empty) 400 InvalidPart One or more of the specified parts could not be found
The bytes are stored and billed; only the browser's view of the response is missing the ETag it needs to finish the job.

Cause. CORS hides every response header except a seven-name safelist, so headers.get("ETag") is null even though S3 sent it. Nothing fails at that moment — the part is stored, and you pay for the transfer. The failure lands one step later, when CompleteMultipartUpload receives an empty ETag and rejects the whole object.

Fix. Add ETag to ExposeHeaders, then make the client fail loudly instead of throwing a TypeError twenty minutes into an upload:

export async function uploadPart(url: string, chunk: Blob): Promise<string> {
  const res = await fetch(url, { method: "PUT", body: chunk, credentials: "omit" });
  if (!res.ok) {
    throw new Error(`part upload failed: ${res.status} ${res.statusText}`);
  }
  const etag = res.headers.get("ETag");
  if (etag === null) {
    throw new Error(
      "ETag is not readable — add ETag to the bucket's ExposeHeaders",
    );
  }
  return etag.replaceAll('"', "");
}

Whether you are exposed to this at all depends on the size threshold at which you switch strategies, which is the subject of multipart versus single-PUT for files under 100 MB.

Configuration gotchas

The fix looks like it did not work

Access-Control-Max-Age lets the browser reuse the old, failing verdict, and DevTools’ “Disable cache” checkbox does not clear the preflight cache. Retest in a fresh incognito window or with curl, which caches nothing. Measure the bucket, not the browser.

You edited a different bucket than the one in the URL

Two things go wrong here. A bucket name reused across accounts or environments means your PutBucketCors landed somewhere the upload never touches — copy the host out of the failing request and compare it character by character. Separately, addressing an eu-west-1 bucket through the global s3.amazonaws.com endpoint produces a 307 redirect, and the Fetch specification forbids following a redirect on a preflight, so Chromium reports Redirect is not allowed for a preflight request. That one is not fixed by any CORS field; sign against the regional endpoint.

One wide-open rule silently answers for everything

S3 stops at the first rule whose origin, method and headers all match. A permissive GET/HEAD playback rule placed at index 0 with AllowedOrigins: ["*"] will absorb read preflights before your upload rule is ever consulted, which is why the script above puts the narrow rule first. The full matching order and its failure modes are worked through in the parent guide; if the same rule needs to exist on another provider, the field names differ enough that configuring CORS for GCS and Azure Blob uploads is a separate exercise.

Your retry loop will hammer a rule that can never match

A blocked preflight produces no response, so a generic wrapper sees a network error and retries — indefinitely, against a configuration that will never allow it. Classify a TypeError: Failed to fetch with no elapsed response as non-retryable, the same way you would treat a 413. The taxonomy is set out in handling 413 and 507 errors during uploads.

Verification

Replay the preflight exactly as the browser would, including the checksum header:

curl -sS -i -X OPTIONS \
  "https://media-uploads.s3.eu-west-1.amazonaws.com/uploads/probe.bin" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type,x-amz-checksum-crc32"

A healthy bucket answers 200 with Access-Control-Allow-Origin: https://app.example.com, Access-Control-Allow-Methods: PUT, POST, an Access-Control-Allow-Headers line naming both requested headers, and Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method. Any missing line is the field to change.

Once it passes by hand, pin it. This runs in Node — not a browser tab, where Origin is a forbidden header name your script may not set:

import { strict as assert } from "node:assert";

const probe = "https://media-uploads.s3.eu-west-1.amazonaws.com/uploads/probe.bin";
const origin = "https://app.example.com";

const res = await fetch(probe, {
  method: "OPTIONS",
  headers: {
    Origin: origin,
    "Access-Control-Request-Method": "PUT",
    "Access-Control-Request-Headers": "content-type,x-amz-checksum-crc32",
  },
});

assert.equal(res.status, 200, `preflight returned ${res.status}`);
assert.equal(res.headers.get("access-control-allow-origin"), origin);

const allowed = (res.headers.get("access-control-allow-headers") ?? "")
  .toLowerCase()
  .split(",")
  .map((name) => name.trim());
for (const required of ["content-type", "x-amz-checksum-crc32"]) {
  assert.ok(allowed.includes(required), `preflight does not allow ${required}`);
}
assert.ok(
  (res.headers.get("access-control-expose-headers") ?? "")
    .toLowerCase()
    .includes("etag"),
  "ETag is not exposed",
);
console.log("preflight contract holds");

Run it in CI against a staging bucket after every infrastructure change. It takes one round trip and catches the drift that otherwise surfaces as a support ticket. The client half of the same contract — the fetch call whose headers this asserts — is covered in the modern Fetch API for uploads.

Frequently Asked Questions

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

Either the browser is serving a cached verdict from before your fix, or it is sending a header your curl replay omitted. Copy the Access-Control-Request-Headers value from the browser’s real OPTIONS request and paste it into the curl command — if that now fails too, you have reproduced the bug outside the cache.

Is a preflight 403 the same as SignatureDoesNotMatch?

No, and they cannot even occur on the same request. The OPTIONS preflight carries no Authorization header and no signed query string, so S3 has nothing to validate; a 403 there is purely a rule mismatch. SignatureDoesNotMatch is a 403 on the subsequent PUT, and it usually means the browser sent a header that was not in X-Amz-SignedHeaders.

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

Only if your code reads it. A single PUT that ignores the response body and headers works without it, but the moment you add chunking, or store the ETag as a content fingerprint for deduplication, the null appears. Exposing it costs nothing and removes a failure mode that only shows up on large files.

Can I just set AllowedHeaders to a wildcard and move on?

Yes, and during an incident you should. ["*"] makes S3 reflect whatever the browser asked for, which is not a security hole because CORS governs what script may read, not what the bucket accepts — the signature still does that. The cost is diagnostic: you lose the signal that tells you an SDK upgrade started sending a new header, so replace it with an explicit list once the fire is out.

Why does the same upload work from localhost but fail in production?

Almost always an origin that never made it into the rule, or a second entry point nobody listed: a preview deployment on a generated hostname, a www. variant, or a custom domain fronted by CloudFront. If a CDN sits in front of the bucket, it must forward Origin, Access-Control-Request-Method and Access-Control-Request-Headers and include them in the cache key, otherwise one origin’s allow header gets served to every other origin.