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-Type of video/mp4 is not on the CORS safelist, and every Azure Put Blob is required to carry x-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

  1. gcloud CLI 480.0.0 or later, authenticated with a principal holding storage.buckets.update on the target bucket (roles/storage.admin covers it; roles/storage.objectAdmin does not).
  2. Node 20+ with @azure/storage-blob 12.x installed for the Azure half.
  3. 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.
  4. Your exact frontend origins, copied from window.location.origin in the failing tab rather than typed from memory.
  5. curl 7.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.

The GCS responseHeader list serves two purposes A single responseHeader array feeds both the Access-Control-Allow-Headers value on the preflight response and the Access-Control-Expose-Headers value on the real upload response. One list, two jobs responseHeader Content-Type ETag Location x-goog-resumable x-goog-meta-user-id Preflight OPTIONS response Access-Control-Allow-Headers: what the browser may send an unlisted request header blocks the upload entirely Real PUT or POST response Access-Control-Expose-Headers: what JavaScript may read an unlisted response header reads back as null
GCS has no allowedHeaders field: the same responseHeader array gates request headers on the preflight and exposes response headers afterwards.

Line-by-line on the fields that matter

  • origin is 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.
  • method needs PUT for a signed-URL upload and POST for a signed POST policy or for initiating a resumable session. Include DELETE if the client ever cancels a resumable upload by issuing DELETE against the session URI — teams routinely forget this one and only discover it when a user hits Cancel.
  • Content-Type in responseHeader is what lets the browser send Content-Type: video/mp4. The CORS safelist only covers application/x-www-form-urlencoded, multipart/form-data and text/plain, so any real media type triggers a preflight that must be answered.
  • Location is required for browser-initiated resumable uploads. The initiating POST returns the session URI in the Location header, and without exposure response.headers.get("Location") is null — the upload appears to succeed and then the client has nowhere to send bytes.
  • x-goog-resumable travels in the request direction: the initiating POST sends x-goog-resumable: start. It is in the list for the preflight’s sake, not the response’s.
  • Range and Content-Range matter once you resume a partial upload, which is the whole point of a resumable upload state machine on the client.
  • x-goog-meta-user-id shows the shape for custom metadata. The field holds literal header names, so each custom key you actually send needs its own entry — there is no x-goog-meta-* prefix form. Keep the list in sync with whatever you later read in metadata indexing and search.
  • maxAgeSeconds: 3600 is 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-type is not optional. Every Put Blob request must carry x-ms-blob-type: BlockBlob, which is a non-safelisted header, so it appears in Access-Control-Request-Headers on the preflight and must be in allowedHeaders. Miss it in the config and the preflight fails; miss it in the client and the PUT fails. Both are shown below.
  • x-ms-version is pinned by the SDK to the service version it was built against. A hand-rolled fetch against a SAS URL can omit it and inherit the account’s defaultServiceVersion, but the SDK always sends it.
  • x-ms-client-request-id is emitted by every SDK call for correlation. Leaving it out of allowedHeaders is the most common reason “it works with plain fetch but not with BlockBlobClient”.
  • 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.
  • exposedHeaders decides what the client can read back. ETag is required to verify a committed block list; x-ms-content-crc64 lets you check integrity against a checksum computed before the upload; x-ms-request-id is the value Azure support will ask for.
  • maxAgeInSeconds is subject to the same browser clamps as GCS.
  • The others filter is the part people skip. setProperties is a whole-document write, so two deployment scripts that each set only their own rule will take turns deleting each other’s.
Azure evaluates only the first matching CORS rule A preflight matches rule one on origin and method, fails its header check, and returns 403 CorsPreflightFailure without ever consulting rule two, which would have allowed the header. OPTIONS preflight Origin: https://app.example.com Request-Headers: x-ms-blob-type Rule 1 origin: * methods: GET,PUT allowedHeaders: content-type first match wins 403 CorsPreflightFailure Rule 2 origin: https://app.example.com allowedHeaders: x-ms-* never evaluated
Azure stops at the first rule matching origin and method; a broad catch-all rule above your upload rule makes the specific one unreachable.

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.

Where the CORS document attaches on each provider S3 and GCS attach CORS at the bucket, covering all objects and prefixes; Azure attaches it at the storage account blob service, covering every container. Where the CORS document attaches Amazon S3 bucket CORSRules here objects, prefixes no prefix scoping Cloud Storage bucket cors_config here objects, prefixes no prefix scoping Azure Blob storage account blob service rules here every container no container scoping Isolation means a separate bucket per origin group, or on Azure a separate storage account.
Azure's grain is the coarsest of the three: one rule list covers every container in the account.

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.