CORS Configuration for Uploads
When a browser uploads a file straight to object storage, the request crosses an origin boundary, and the browser refuses to send it until the storage bucket explicitly grants permission. Cross-Origin Resource Sharing (CORS) is that grant, and a single missing header is the difference between a working uploader and a console full of red errors.
This guide explains why the preflight OPTIONS request happens, which headers control it, and how to write a correct bucket policy for Amazon S3, Google Cloud Storage, and Azure Blob. It sits under Backend Validation & Cloud Storage Architecture and pairs closely with S3 presigned URL workflows, because the moment you hand a signed URL to a browser you also have to make the bucket accept a cross-origin request to it.
Prerequisites
- [ ] Node 20+ with
@aws-sdk/client-s3installed for verification scripts - [ ] A storage bucket you control (S3, GCS, or Azure Blob)
- [ ] IAM or service-account permission to edit the bucket CORS configuration
- [ ] The exact frontend origin(s) that will issue uploads, including scheme and port
- [ ]
curlavailable locally to replay the preflight request - [ ] A working signed upload URL (see generating secure presigned URLs)
Why the browser sends a preflight request
The same-origin policy treats https://app.example.com and https://my-bucket.s3.amazonaws.com as different origins. A βsimpleβ cross-origin request (a GET, or a POST with a form-encoded body) is allowed to go out, and the browser only checks the response afterward. A direct upload is never simple: it is usually a PUT with a Content-Type of image/png or similar, and any PUT β or any request carrying a non-simple header β forces the browser to ask permission first.
That permission request is the preflight: an automatic OPTIONS request the browser fires before the real upload. It carries three headers describing what the real request intends to do β Origin, Access-Control-Request-Method, and Access-Control-Request-Headers. The storage service inspects them against its CORS rules and answers with matching Access-Control-Allow-* headers. Only if the answer covers everything the real request needs does the browser proceed. If it does not, the upload never leaves the browser and you see a generic network failure with no HTTP status.
The five fields that matter
Every storage provider expresses the same five concepts, even though the JSON keys differ:
- AllowedOrigins β the exact origins permitted to make the request. A value of
https://app.example.commatches only that scheme, host, and default port.*allows any origin but is incompatible with credentialed requests. - AllowedMethods β the HTTP verbs the browser may use. For uploads you need
PUTand/orPOST. Multipart and resumable flows also need the methods that initiate and complete the upload. - AllowedHeaders β the request headers the browser may send. Anything beyond the CORS-safelisted set (
Accept,Accept-Language,Content-Language, and a narrowContent-Type) must be listed here, includingContent-Type: image/pngand anyx-amz-*headers. - ExposeHeaders β the response headers JavaScript is allowed to read. By default only a handful are visible;
ETagis hidden unless you expose it, which breaks multipart uploads that need the part ETags. - MaxAgeSeconds β how long the browser may cache a successful preflight result, sparing you an
OPTIONSround trip on every upload.
Step 1: Pin down your exact origins
CORS origins are matched literally, not by substring. https://example.com and https://www.example.com are different origins, and so are http://localhost:3000 and http://localhost:5173. List every origin your app actually serves from, including local development ports. Resist the urge to use * in production; if you ever need credentialed requests it will stop working, and a wildcard advertises your bucket to any page on the internet.
// The single source of truth for which origins may upload.
export const ALLOWED_ORIGINS = [
"https://app.example.com",
"https://staging.example.com",
"http://localhost:5173",
] as const;
Step 2: Apply the S3 CORS configuration
S3 accepts CORS as a JSON array of rules through the SDK or console. Each rule maps directly onto the five fields. Exposing ETag is mandatory if the frontend completes multipart uploads, because each part response is identified by its ETag.
import {
S3Client,
PutBucketCorsCommand,
GetBucketCorsCommand,
} from "@aws-sdk/client-s3";
import { ALLOWED_ORIGINS } from "./origins.js";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function applyUploadCors(bucket: string): Promise<void> {
await s3.send(
new PutBucketCorsCommand({
Bucket: bucket,
CORSConfiguration: {
CORSRules: [
{
AllowedOrigins: [...ALLOWED_ORIGINS],
AllowedMethods: ["PUT", "POST", "GET", "HEAD"],
AllowedHeaders: ["*"],
ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-version-id"],
MaxAgeSeconds: 3000,
},
],
},
}),
);
const current = await s3.send(new GetBucketCorsCommand({ Bucket: bucket }));
console.log(JSON.stringify(current.CORSRules, null, 2));
// Expected: the rule you just wrote, echoed back by S3.
}
AllowedHeaders: ["*"] tells S3 to reflect whatever headers the browser asks for in Access-Control-Request-Headers, which is the pragmatic choice for uploads that may carry varying Content-Type and x-amz-meta-* values. Tighten it to an explicit list once you know the exact headers your client sends.
Step 2 (GCS): Apply the bucket CORS configuration
Google Cloud Storage uses responseHeader where S3 uses ExposeHeaders, and method/origin for the rest. The same ETag exposure rule applies for resumable uploads.
[
{
"origin": ["https://app.example.com", "http://localhost:5173"],
"method": ["PUT", "POST", "GET", "HEAD"],
"responseHeader": ["Content-Type", "ETag", "x-goog-resumable"],
"maxAgeSeconds": 3000
}
]
Apply it with the SDK so it lives in code rather than a one-off console edit. See uploading to GCS with the Node.js client libraries for the matching signed-URL generation.
import { Storage } from "@google-cloud/storage";
const storage = new Storage();
export async function applyGcsCors(bucketName: string): Promise<void> {
await storage.bucket(bucketName).setCorsConfiguration([
{
origin: ["https://app.example.com", "http://localhost:5173"],
method: ["PUT", "POST", "GET", "HEAD"],
responseHeader: ["Content-Type", "ETag", "x-goog-resumable"],
maxAgeSeconds: 3000,
},
]);
console.log("GCS CORS applied");
// Expected: "GCS CORS applied" with no thrown error.
}
Step 2 (Azure): Apply the service CORS configuration
Azure Blob sets CORS on the storage accountβs Blob service rather than per-container, and it splits allowed and exposed headers into separate fields. Note the leading-dot origin syntax is not supported; list full origins.
import {
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
const credential = new StorageSharedKeyCredential(
process.env.AZURE_ACCOUNT_NAME!,
process.env.AZURE_ACCOUNT_KEY!,
);
const service = new BlobServiceClient(
`https://${process.env.AZURE_ACCOUNT_NAME}.blob.core.windows.net`,
credential,
);
export async function applyAzureCors(): Promise<void> {
await service.setProperties({
cors: [
{
allowedOrigins: "https://app.example.com,http://localhost:5173",
allowedMethods: "PUT,POST,GET,HEAD,OPTIONS",
allowedHeaders: "*",
exposedHeaders: "ETag,x-ms-request-id",
maxAgeInSeconds: 3000,
},
],
});
console.log("Azure CORS applied");
// Expected: "Azure CORS applied" with no thrown error.
}
The full SDK upload flow lives in uploading to Azure Blob with the storage JS SDK.
Configuration reference
| Field (S3 / GCS / Azure) | Type | Default | Effect |
|---|---|---|---|
| AllowedOrigins / origin / allowedOrigins | string[] | none | Origins permitted to send the request; matched exactly. * blocks credentialed mode. |
| AllowedMethods / method / allowedMethods | string[] | none | HTTP verbs allowed. Need PUT/POST for uploads; add GET/HEAD for reads. |
| AllowedHeaders / (implicit) / allowedHeaders | string[] | safelist only | Request headers the browser may send. * reflects requested headers. |
| ExposeHeaders / responseHeader / exposedHeaders | string[] | safelist only | Response headers JS may read. Add ETag for multipart/resumable. |
| MaxAgeSeconds / maxAgeSeconds / maxAgeInSeconds | number | 0 (no cache) | Seconds the browser caches the preflight result. 3000 is a sane default. |
Edge cases & gotchas
Credentials mode breaks wildcard origins
If your client sets credentials: "include" on fetch (sending cookies), the browser requires Access-Control-Allow-Origin to echo a specific origin, never *, and requires Access-Control-Allow-Credentials: true. Most direct uploads use signed URLs and should set credentials: "omit" so the bucket never needs cookies β that keeps a wildcard origin legal during development and avoids leaking session cookies to the storage host.
Hidden ETag silently breaks multipart completion
The upload succeeds, the part lands in the bucket, but response.headers.get("ETag") returns null because ETag was not in ExposeHeaders. The completion request then sends an empty ETag and the provider rejects the assembled object. Always expose ETag for any chunked or resumable upload state machine.
Preflight is cached, so fixes look like they failed
After you correct a rule, the browser may still serve the old preflight answer from cache for up to MaxAgeSeconds. Test in a fresh incognito window or with curl (which never caches) so you measure the bucket, not the browser cache.
Signed headers must also be CORS-allowed
A presigned URL signs a specific set of headers. If Content-Type is part of the signature it must also appear in AllowedHeaders, or the preflight rejects it before the signature is ever checked. CORS runs first; the signature check is a separate, later gate.
Verification with curl
Replay exactly what the browser sends. A correct preflight returns 204 (or 200) with Access-Control-Allow-Origin echoing your origin and Access-Control-Allow-Methods listing PUT.
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"
Look for these lines in the response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3000
If Access-Control-Allow-Origin is missing entirely, the rule does not match your origin or method. For a catalog of the specific browser errors and their fixes, read fixing CORS preflight errors on S3 uploads.
FAQ
Why does my upload fail with no HTTP status code in the console?
A failed CORS preflight is blocked by the browser before the real request is sent, so there is no response to attach a status to. Open the Network tab, find the OPTIONS entry, and inspect its response headers β that is where the real failure lives.
Do I need CORS if I upload through my own API server instead of the browser?
No. CORS is enforced only by browsers on cross-origin requests. Server-to-server uploads, such as proxying through your API, never trigger preflight. The trade-off between those approaches is covered in presigned URL vs server proxy tradeoffs.
Can I use a wildcard origin in production?
You can for non-credentialed signed-URL uploads, but it is poor hygiene: it lets any website POST to your bucket using a leaked signed URL and disqualifies you from ever using cookie-based requests. Enumerate your real origins instead.
Why is ETag null even though the upload returned 200?
ETag is not exposed by default. Add it to ExposeHeaders (S3), responseHeader (GCS), or exposedHeaders (Azure) so JavaScript can read it after the request completes.