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
OPTIONSrow before the upload, or afetch()that rejects withTypeError: Failed to fetchcarrying no status code and no response object. - A chunked upload transfers every part with a
200and then fails atCompleteMultipartUpload.
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
- Node 20+ with
@aws-sdk/client-s33.700 or later for the scripts below. AWS_REGIONset 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"
}
]
}
- The exact failing origin, copied from
window.location.originin the broken tab rather than typed from memory. curl7.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.
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
AllowedOriginsis compared as a string after normalisation, sohttps://app.example.comcovers neitherhttps://www.app.example.comnorhttp://app.example.comnorhttps://app.example.com:8443. S3 permits a single*character per entry, which makeshttps://*.example.comlegal andhttps://*.*.example.coma validation error.AllowedMethodsaccepts onlyGET,PUT,HEAD,POSTandDELETE. AddingOPTIONS, as Azure’s documentation tells you to, fails the API call outright.AllowedHeadersis 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.ExposeHeaderstakes no wildcard on S3.ETagmust be spelled out;x-amz-request-idandx-amz-id-2are the two values AWS Support asks for when you open a ticket, so expose them while you are there.MaxAgeSeconds: 3000sits 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
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
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, "")
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.