Configuring CORS for GCS and Azure Blob Uploads
Google Cloud Storage takes a bucket-level JSON array applied with gcloud storage buckets update --cors-file, Azure Blob takes an account-wide rule list written through BlobServiceClient.setProperties, and neither will let a browser PUT through until every non-safelisted request header your client actually sends — Content-Type on GCS, x-ms-blob-type on Azure — is named in the allow list.
This is the non-S3 half of CORS configuration for uploads, within Backend Validation & Cloud Storage Architecture. If your storage is on AWS, the field names and the error catalogue are different enough that you want fixing CORS preflight errors on S3 uploads instead. Everything below assumes the upload already works from curl with a signed credential — a V4 signed POST policy or resumable session URI on GCS, or a blob-scoped SAS on Azure — and fails only from a browser tab.
When to use this approach
- You are uploading direct from the browser to GCS or Azure Blob, so the request crosses an origin boundary and the browser demands a preflight before it will send the body.
- Your client sends at least one non-safelisted header. That is effectively always true: a
Content-Typeofvideo/mp4is not on the CORS safelist, and every AzurePut Blobis required to carryx-ms-blob-type. - You need per-origin control that outlives a console click, so the configuration belongs in a file you can diff and re-apply.
If uploads pass through your own API and you re-upload server-side, none of this applies — CORS is a browser-enforced policy and server-to-server requests never preflight. That trade-off is the subject of the wider direct-to-cloud upload patterns comparison.
Prerequisites
gcloudCLI 480.0.0 or later, authenticated with a principal holdingstorage.buckets.updateon the target bucket (roles/storage.admincovers it;roles/storage.objectAdmindoes not).- Node 20+ with
@azure/storage-blob12.x installed for the Azure half. - The Azure storage account key, or an ARM-side deployment. Setting blob service properties is an account-owner operation; a user delegation SAS or a plain RBAC token is rejected.
- Your exact frontend origins, copied from
window.location.originin the failing tab rather than typed from memory. curl7.75+ locally, so you can replay a preflight without a browser cache in the way.
Implementation: the GCS bucket CORS document
GCS wants a top-level JSON array. Each entry has exactly four keys, and there is no separate field for allowed request headers — responseHeader does both jobs at once, which is the single fact that resolves most GCS CORS tickets.
[
{
"origin": [
"https://app.example.com",
"https://staging.example.com",
"http://localhost:5173"
],
"method": ["GET", "HEAD", "PUT", "POST", "DELETE"],
"responseHeader": [
"Content-Type",
"Content-Length",
"Content-Range",
"Range",
"Location",
"ETag",
"x-goog-resumable",
"x-goog-generation",
"x-goog-hash",
"x-goog-meta-user-id"
],
"maxAgeSeconds": 3600
}
]
Apply it and read it straight back — the read-back is the only proof the file parsed the way you meant:
gcloud storage buckets update gs://acme-media-uploads --cors-file=cors.json
gcloud storage buckets describe gs://acme-media-uploads \
--format="default(cors_config)"
To take CORS off a bucket entirely, gcloud storage buckets update gs://acme-media-uploads --clear-cors — note that writing an empty array [] in the file does the same thing, which is an easy way to disable uploads by accident during a refactor.
Line-by-line on the fields that matter
originis matched literally, scheme and port included. A trailing slash is not an origin and will silently never match. Wildcards are all-or-nothing:"*"works,"https://*.example.com"does not.methodneedsPUTfor a signed-URL upload andPOSTfor a signed POST policy or for initiating a resumable session. IncludeDELETEif the client ever cancels a resumable upload by issuingDELETEagainst the session URI — teams routinely forget this one and only discover it when a user hits Cancel.Content-TypeinresponseHeaderis what lets the browser sendContent-Type: video/mp4. The CORS safelist only coversapplication/x-www-form-urlencoded,multipart/form-dataandtext/plain, so any real media type triggers a preflight that must be answered.Locationis required for browser-initiated resumable uploads. The initiatingPOSTreturns the session URI in theLocationheader, and without exposureresponse.headers.get("Location")isnull— the upload appears to succeed and then the client has nowhere to send bytes.x-goog-resumabletravels in the request direction: the initiating POST sendsx-goog-resumable: start. It is in the list for the preflight’s sake, not the response’s.RangeandContent-Rangematter once you resume a partial upload, which is the whole point of a resumable upload state machine on the client.x-goog-meta-user-idshows the shape for custom metadata. The field holds literal header names, so each custom key you actually send needs its own entry — there is nox-goog-meta-*prefix form. Keep the list in sync with whatever you later read in metadata indexing and search.maxAgeSeconds: 3600is a ceiling, not a promise. Chrome clamps any preflight cache to 7200 seconds, Firefox to 86400, and Safari to 600, so a value of 86400 buys you nothing on most traffic and makes every mistake linger for two hours.
Entries are evaluated top to bottom. Unless you genuinely need different header sets per origin, keep it to one entry — two entries where the first matches on origin and method but omits a header is a failure mode with no useful error text.
Implementation: Azure Blob service properties
Azure attaches CORS to the storage account’s Blob service, not to a container. The rule fields are comma-separated strings rather than arrays in both the REST body and the JS SDK types, and setProperties replaces the whole rule list, so the safe pattern is read, merge, write.
import {
BlobServiceClient,
StorageSharedKeyCredential,
type BlobServiceProperties,
} from "@azure/storage-blob";
const account = process.env.AZURE_ACCOUNT_NAME as string;
const service = new BlobServiceClient(
`https://${account}.blob.core.windows.net`,
new StorageSharedKeyCredential(
account,
process.env.AZURE_ACCOUNT_KEY as string,
),
);
const UPLOAD_ORIGINS = [
"https://app.example.com",
"https://staging.example.com",
"http://localhost:5173",
].join(",");
const UPLOAD_RULE = {
allowedOrigins: UPLOAD_ORIGINS,
// OPTIONS here is cosmetic: preflight is matched on Access-Control-Request-Method.
allowedMethods: ["GET", "HEAD", "PUT", "OPTIONS"].join(","),
// Every one of these is sent by @azure/storage-blob from a browser.
allowedHeaders: [
"content-type",
"x-ms-blob-type",
"x-ms-blob-content-type",
"x-ms-version",
"x-ms-client-request-id",
"x-ms-meta-*", // at most 2 prefixed entries are permitted per list
].join(","),
exposedHeaders: [
"ETag",
"Content-MD5",
"x-ms-request-id",
"x-ms-version",
"x-ms-content-crc64",
"x-ms-request-server-encrypted",
].join(","),
maxAgeInSeconds: 3600,
};
export async function applyBlobCors(): Promise<void> {
const existing = await service.getProperties();
// Keep any rule owned by another team; drop our own previous version.
const others = (existing.cors ?? []).filter(
(rule) => rule.allowedOrigins !== UPLOAD_ORIGINS,
);
const next: BlobServiceProperties = {
cors: [UPLOAD_RULE, ...others].slice(0, 5), // hard limit: 5 rules per service
defaultServiceVersion: existing.defaultServiceVersion,
blobAnalyticsLogging: existing.blobAnalyticsLogging,
hourMetrics: existing.hourMetrics,
minuteMetrics: existing.minuteMetrics,
deleteRetentionPolicy: existing.deleteRetentionPolicy,
staticWebsite: existing.staticWebsite,
};
await service.setProperties(next);
const after = await service.getProperties();
console.log(JSON.stringify(after.cors, null, 2));
// Expected: your rule first, then any pre-existing rules you preserved.
}
Line-by-line on the fields that matter
x-ms-blob-typeis not optional. EveryPut Blobrequest must carryx-ms-blob-type: BlockBlob, which is a non-safelisted header, so it appears inAccess-Control-Request-Headerson the preflight and must be inallowedHeaders. Miss it in the config and the preflight fails; miss it in the client and thePUTfails. Both are shown below.x-ms-versionis pinned by the SDK to the service version it was built against. A hand-rolledfetchagainst a SAS URL can omit it and inherit the account’sdefaultServiceVersion, but the SDK always sends it.x-ms-client-request-idis emitted by every SDK call for correlation. Leaving it out ofallowedHeadersis the most common reason “it works with plainfetchbut not withBlockBlobClient”.x-ms-meta-*is a genuine prefix wildcard, which Azure supports and GCS does not. The limit is 64 literal names plus 2 prefixed names per list, and the entire CORS settings body must stay under 2 KiB.exposedHeadersdecides what the client can read back.ETagis required to verify a committed block list;x-ms-content-crc64lets you check integrity against a checksum computed before the upload;x-ms-request-idis the value Azure support will ask for.maxAgeInSecondsis subject to the same browser clamps as GCS.- The
othersfilter is the part people skip.setPropertiesis a whole-document write, so two deployment scripts that each set only their own rule will take turns deleting each other’s.
Order therefore matters on Azure in a way it rarely does on S3. Put the narrowest rule first, and treat a leftover allowedOrigins: "*" rule from someone’s static-site experiment as an outage waiting to happen.
Neither provider scopes CORS to a path
This is the constraint that shapes multi-tenant designs. A GCS CORS document applies to every object in the bucket, and an Azure rule list applies to every container in the storage account. There is no prefix condition, no container filter, no per-object override.
The practical consequence: if a partner-facing origin must be able to upload but must never be able to read a preflight-approved response from your main media store, separate the storage, not the paths. On Azure that means an extra storage account, which also gives you an independent throughput target and an independent key rotation schedule. The per-provider trade-offs beyond CORS are laid out in S3 vs GCS vs Azure Blob for media uploads.
Configuration gotchas
GCS: No 'Access-Control-Allow-Origin' header is present
Access to fetch at 'https://storage.googleapis.com/acme-media-uploads/uploads/clip.mp4'
from origin 'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
No entry matched both the origin and the method. GCS answers the OPTIONS with 200 and simply omits the Access-Control-* headers — there is no error body, no status code to grep, which is why the curl probe below is the only reliable diagnostic. One non-obvious cause: CORS headers are served by storage.googleapis.com and BUCKET.storage.googleapis.com, not by storage.cloud.google.com, which is the cookie-authenticated console host and will never return them regardless of your configuration.
GCS: Request header field x-goog-meta-user-id is not allowed
Access to fetch at 'https://storage.googleapis.com/acme-media-uploads/uploads/clip.mp4'
from origin 'https://app.example.com' has been blocked by CORS policy:
Request header field x-goog-meta-user-id is not allowed by
Access-Control-Allow-Headers in preflight response.
The origin and method matched, so the rule is close, but the named header is absent from responseHeader. This is the double-duty trap: engineers read the field name, decide it is about responses, and never think to add the request headers they send. Add every header the client sets, Content-Type included.
Azure: 403 CorsPreflightFailure
The OPTIONS itself returns HTTP 403 with a readable body — a genuine advantage over the GCS silence:
<?xml version="1.0" encoding="utf-8"?>
<Error>
<Code>CorsPreflightFailure</Code>
<Message>CORS not enabled or no matching rule found for this request.
RequestId:9c4e1f0e-201e-0042-4a1b-6f3d1c000000
Time:2026-07-26T09:41:12.4471203Z</Message>
<MessageDetails>No CORS rules matches this request</MessageDetails>
</Error>
Either the account has no rules at all, or the first rule matching your origin and method rejected a header. Re-run the probe with a single header at a time to find which one.
Azure: x-ms-blob-type fails in two different places
If the header is missing from allowedHeaders, you never reach the service:
Access to XMLHttpRequest at 'https://acmemedia.blob.core.windows.net/uploads/clip.mp4?sv=2025-01-05'
from origin 'https://app.example.com' has been blocked by CORS policy:
Request header field x-ms-blob-type is not allowed by
Access-Control-Allow-Headers in preflight response.
If CORS is correct but your client forgot to send it, the preflight passes and the real PUT returns HTTP 400:
<?xml version="1.0" encoding="utf-8"?>
<Error>
<Code>MissingRequiredHeader</Code>
<Message>An HTTP header that's mandatory for this request is not specified.
RequestId:9c4e1f0e-201e-0042-4a1b-6f3d1c000001
Time:2026-07-26T09:41:19.8830517Z</Message>
<HeaderName>x-ms-blob-type</HeaderName>
</Error>
BlockBlobClient sets it for you. A hand-written upload — the pattern in uploading files with fetch and FormData, adapted to a raw body — must set it explicitly:
const res = await fetch(sasUrl, {
method: "PUT",
headers: {
"x-ms-blob-type": "BlockBlob",
"content-type": file.type || "application/octet-stream",
},
body: file,
});
if (!res.ok) {
throw new Error(`Azure PUT ${res.status}: ${await res.text()}`);
}
The fix looks like it did nothing
Both providers apply configuration within seconds, but the browser is still holding the old preflight answer for up to maxAgeSeconds / maxAgeInSeconds, clamped to two hours in Chrome. Verify with curl, which never caches, before you touch the configuration a second time. Chrome’s preflight cache is also keyed per origin and per credentials mode, so switching a fetch between credentials: "omit" and "include" silently re-preflights and can make an intermittent failure look random.
Verification
Probe each service directly. CORS is evaluated before authentication and before the object is looked up, so an unsigned probe against a key that does not exist still tells you the truth about the rule.
# GCS — expect Access-Control-Allow-Origin echoing your origin.
curl -s -D - -o /dev/null -X OPTIONS \
"https://storage.googleapis.com/acme-media-uploads/uploads/probe.mp4" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type,x-goog-meta-user-id"
# Azure — expect HTTP 200, not 403 CorsPreflightFailure.
curl -s -D - -o /dev/null -X OPTIONS \
"https://acmemedia.blob.core.windows.net/uploads/probe.mp4" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: x-ms-blob-type,x-ms-version,content-type"
A healthy GCS response looks like this — note that Access-Control-Allow-Headers echoes only the headers you asked about that were also in responseHeader, so a short echo is the tell that one is missing:
HTTP/2 200
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET,HEAD,PUT,POST,DELETE
access-control-allow-headers: content-type,x-goog-meta-user-id
access-control-max-age: 3600
access-control-expose-headers: content-type,etag,location,x-goog-resumable
And a healthy Azure response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,HEAD,PUT,OPTIONS
Access-Control-Allow-Headers: x-ms-blob-type,x-ms-version,content-type
Access-Control-Max-Age: 3600
x-ms-request-id: 9c4e1f0e-201e-0042-4a1b-6f3d1c000002
Wire both probes into CI against a scratch bucket and a scratch storage account. Bucket configuration drifts — someone clears CORS to debug a static site, someone’s Terraform plan reorders the Azure rule list — and the failure only surfaces in a browser, which your integration tests are usually not.
Frequently Asked Questions
Does GCS really have no separate field for allowed request headers?
Correct. The four keys are origin, method, responseHeader and maxAgeSeconds, and responseHeader is consulted for both Access-Control-Allow-Headers on the preflight and Access-Control-Expose-Headers on the real response. If a header appears in either direction of your upload, it belongs in that array.
Can I set Azure Blob CORS for one container only?
No. Set Blob Service Properties is account-wide, and there is no container-level override. If two applications need incompatible rules, give them separate storage accounts — that is also the only way to stop a broad allowedOrigins: "*" rule from one team shadowing another team’s narrower rule, because Azure stops at the first rule matching origin and method.
Why does my Azure upload work in Postman but fail in the browser?
Postman does not send an Origin header and does not preflight, so it exercises the SAS and the blob service while skipping the CORS layer entirely. Reproduce with the curl -X OPTIONS probe above, which does send Origin and is the closest non-browser equivalent to what Chrome actually asks.
Do I need CORS configured for a resumable or chunked upload specifically?
Yes, and more of it. On GCS the initiating POST sends x-goog-resumable and the response’s Location must be exposed; on Azure each Put Block sends x-ms-blob-type and the final Put Block List needs ETag exposed. Chunked flows also retry more, so a broken rule shows up as a stall rather than a clean failure — see resuming uploads after network loss for how to distinguish the two.
Is origin: ["*"] acceptable for signed-URL uploads?
It is legal, because signed uploads should run with credentials: "omit" and a wildcard origin is only rejected in credentialed mode. It is still a poor default: a leaked SAS or signed URL becomes usable from any page on the internet, and you cannot later add cookie-based requests without a rewrite. Enumerate your origins.