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
PUTorPOSTupload fails only in the browser, never incurlor Postman. - The browser Network tab shows a failed
OPTIONSrequest before the upload. - A multipart upload completes per-part but fails to finalize.
Prerequisites
- Permission to call
PutBucketCorson the target bucket. - The exact origin your app serves from, including scheme and port.
curlto replay the preflight without browser caching.
Map each error to its 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
AllowedOriginsis matched character-for-character.https://app.example.comdoes not coverhttps://www.app.example.com, norhttp://app.example.com. List each real origin.AllowedMethodscontrols theAccess-Control-Allow-Methodsresponse. IfPUTis absent, the preflight for aPUTupload returns a403and the browser blocks the upload.AllowedHeaders: ["*"]makes S3 reflect whatever the browser names inAccess-Control-Request-Headers. Without it, sendingContent-Type: image/pngtrips a header-not-allowed error.ExposeHeadersmust includeETagfor any chunked upload, orresponse.headers.get("ETag")returnsnull.
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.