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 a single byte until the storage bucket explicitly grants permission. Cross-Origin Resource Sharing (CORS) is that grant, and one missing header name is the difference between a working uploader and a console full of red errors with no HTTP status attached to them.

This guide explains the mechanism underneath the preflight OPTIONS request, how a storage service decides which rule to apply, and how to write, deploy and verify a configuration you can defend in review. 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-s3 v3.600 or later installed for the configuration and verification scripts
  • [ ] A storage bucket you control, and IAM permission for s3:PutBucketCORS and s3:GetBucketCORS on it
  • [ ] The exact frontend origins that will issue uploads, copied from window.location.origin rather than typed from memory
  • [ ] A working signed upload URL — see generating secure presigned URLs with AWS SDK v3
  • [ ] curl 7.75+ locally, so you can replay a preflight with no browser cache in the way
  • [ ] A browser with DevTools open on the Network tab, filtered to Other so OPTIONS requests are visible

How it works

The same-origin policy and the safelist

The same-origin policy treats https://app.example.com and https://media.s3.eu-west-1.amazonaws.com as unrelated origins: different host, therefore different security context. The Fetch specification carves out a small set of requests that are allowed to leave the browser without asking first, on the grounds that an HTML form could have sent them anyway. Those are the simple requests: GET, HEAD, or POST, carrying only CORS-safelisted headers.

The safelist is much shorter than people assume. It is Accept, Accept-Language, Content-Language, Range with a simple byte range, and Content-Type — but only when its value is application/x-www-form-urlencoded, multipart/form-data, or text/plain. A Content-Type of video/mp4 or image/png is not safelisted. Neither is any header beginning x-amz-, x-goog- or x-ms-. Each safelisted value is also capped at 128 bytes.

That makes a direct upload structurally incapable of being simple. A presigned PUT uses a non-safelisted verb; a presigned POST uses multipart/form-data, which is safelisted, and so genuinely can avoid a preflight — one of the quieter reasons to prefer it for simple single-shot uploads. Everything else preflights.

The preflight exchange

The preflight is an automatic OPTIONS request the browser fires before your code’s request. You never write it; you cannot intercept it; it does not carry cookies, an Authorization header, or the request body. It carries exactly three things that matter: Origin, Access-Control-Request-Method, and Access-Control-Request-Headers — a comma-separated, lowercased, alphabetically sorted list of every non-safelisted header your real request will set.

The storage service compares those three values against its CORS rules and answers with Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers. The browser then checks the answer covers everything the real request needs. If any single requested header is missing from the allow list, the real request is never sent, and fetch() rejects with a TypeError: Failed to fetch that carries no status code, no response object, and nothing you can log server-side.

Preflight then upload sequence between browser and bucket A browser sends an OPTIONS preflight to the bucket, receives Access-Control-Allow headers with a max age, then sends the PUT upload and reads the exposed ETag from the response. Browser app.example.com S3 bucket regional endpoint OPTIONS /uploads/clip.mp4 Origin, Request-Method: PUT, Request-Headers 200 with Access-Control-Allow-* Max-Age: 3000 caches the verdict PUT clip.mp4 with signed query string Content-Type must equal the signed value 200 with ETag and Expose-Headers unexposed headers stay invisible to script A blocked preflight yields no status code at all
The browser negotiates permission before the body is sent, and only headers you explicitly expose are readable afterwards.

CORS is not authorisation

This trips up experienced engineers more than anything else on the page. A preflight to S3 is unauthenticated. S3 evaluates the CORS document before it evaluates the signature, the bucket policy, or the object ACL. A 200 on the OPTIONS proves only that the bucket is willing to talk to your origin — it says nothing about whether the PUT that follows will be accepted.

The consequences run in both directions. A preflight can succeed and the upload still fail with 403 SignatureDoesNotMatch, because the signature is checked in a later, separate gate. And a wide-open CORS document is not a data breach on its own: an attacker still needs a valid signature. CORS controls who may read the response in a browser, not who may reach the bucket. If you want to constrain who can obtain a signature in the first place, that belongs in upload rate limiting and abuse protection, not here.

The preflight cache

A preflight doubles the round trips on every upload, which is why the browser caches the verdict. The cache is keyed by the tuple of origin, request URL, and credentials mode — not by the bucket, and not by the path prefix. Uploading to /uploads/a.mp4 and then /uploads/b.mp4 produces two cache entries and therefore two preflights, which is why an uploader that fires 400 chunked PUTs to 400 distinct keys pays 400 extra round trips unless the parts share a URL.

Access-Control-Max-Age sets how long an entry lives, but every engine clamps it. Chromium caps at 7200 seconds, Firefox at 86400, and WebKit at 600. If the header is absent entirely, Chromium falls back to a five-second cache, which is exactly long enough to make a broken configuration look intermittently fine on a fast connection.

Anatomy of a browser preflight cache entry The cache key is origin, URL and credentials mode; the stored entry holds allowed methods, allowed headers and an expiry, and each browser clamps the maximum age differently. What the browser stores after a successful preflight Cache key origin: app.example.com url: /uploads/clip.mp4 credentials: omit Cached entry methods: PUT, POST headers: content-type expires: now + 3000 s any field differs, so re-preflight Engine caps on Access-Control-Max-Age Chrome and Edge clamps to 7200 s Firefox clamps to 86400 s Safari clamps to 600 s Set 3000 and every engine honours the value verbatim
The cache key includes the full URL, so uploading each chunk to a different key re-preflights every time.

The five fields that matter

Every provider expresses the same five concepts, even though the JSON keys differ:

  • AllowedOrigins — the origins permitted to make the request, matched as exact strings after scheme, host and port normalisation. S3 permits one * wildcard character per entry, so https://*.example.com is legal; GCS and Azure accept * only as the entire value.
  • AllowedMethods — the HTTP verbs the browser may use. Uploads need PUT or POST; multipart flows also need the verbs that initiate, list and complete the upload.
  • AllowedHeaders — the request headers the browser may send beyond the safelist. Matching is case-insensitive, and S3 accepts one * per entry, so x-amz-meta-* covers a whole family of custom metadata headers.
  • ExposeHeaders — the response headers JavaScript may read. The default visible set is Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. ETag is not on it.
  • MaxAgeSeconds — how long the browser may cache the preflight verdict.

Step-by-step implementation

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. A trailing slash makes the entry match nothing at all, because an origin serialisation never has a path. Keep the list in one module so the same values feed the bucket configuration, the API’s own CORS middleware, and your tests.

// origins.ts — the single source of truth for who may upload.
export const PROD_ORIGINS = [
  "https://app.example.com",
  "https://studio.example.com",
] as const;

export const DEV_ORIGINS = [
  "https://staging.example.com",
  "http://localhost:5173",
  "http://localhost:4173",
] as const;

export function originsFor(env: "production" | "development"): string[] {
  return env === "production" ? [...PROD_ORIGINS] : [...PROD_ORIGINS, ...DEV_ORIGINS];
}

Keeping development origins out of the production bucket matters more than it looks. http://localhost:5173 in a production CORS document means any locally running page — including a malicious one a user was tricked into starting — can read responses from your bucket with a leaked signature.

Step 2 — Write the S3 CORS document

S3 stores CORS as an ordered array of rules, applied through PutBucketCorsCommand. Two rules is the shape that survives contact with production: a narrow write rule for the origins that upload, and a wider read rule for the origins that display media. Splitting them means a compromised read origin cannot PUT.

// apply-cors.ts
import {
  S3Client,
  PutBucketCorsCommand,
  GetBucketCorsCommand,
  type CORSRule,
} from "@aws-sdk/client-s3";
import { originsFor } from "./origins.js";

const s3 = new S3Client({ region: process.env.AWS_REGION ?? "eu-west-1" });

export function uploadCorsRules(env: "production" | "development"): CORSRule[] {
  return [
    {
      ID: "browser-uploads",
      AllowedOrigins: originsFor(env),
      AllowedMethods: ["PUT", "POST"],
      AllowedHeaders: [
        "content-type",
        "content-md5",
        "x-amz-checksum-crc32",
        "x-amz-sdk-checksum-algorithm",
        "x-amz-meta-*",
      ],
      ExposeHeaders: ["ETag", "x-amz-checksum-crc32", "x-amz-request-id", "x-amz-id-2"],
      MaxAgeSeconds: 3000,
    },
    {
      ID: "media-playback",
      AllowedOrigins: originsFor(env),
      AllowedMethods: ["GET", "HEAD"],
      AllowedHeaders: ["range", "if-none-match"],
      ExposeHeaders: ["ETag", "Content-Range", "Accept-Ranges", "Content-Length"],
      MaxAgeSeconds: 86400,
    },
  ];
}

export async function applyUploadCors(bucket: string, env: "production" | "development") {
  await s3.send(
    new PutBucketCorsCommand({
      Bucket: bucket,
      CORSConfiguration: { CORSRules: uploadCorsRules(env) },
    }),
  );
  const current = await s3.send(new GetBucketCorsCommand({ Bucket: bucket }));
  console.log(JSON.stringify(current.CORSRules, null, 2));
  return current.CORSRules ?? [];
}

Three details in that document earn their place. x-amz-checksum-crc32 is what the browser sends when you attach an integrity value, as covered in computing file checksums in the browser with Web Crypto — omit it and every checksummed upload fails preflight while unchecksummed ones sail through. x-amz-meta-* uses S3’s single-wildcard matching so you do not have to redeploy the bucket every time the product adds a metadata field. And x-amz-id-2 in ExposeHeaders is what lets your frontend log the two identifiers AWS Support will ask for.

AllowedHeaders: ["*"] is the shortcut, and it is defensible while you are still discovering which headers your client sends: S3 will reflect whatever appears in Access-Control-Request-Headers. Replace it with the explicit list before launch, then a new header in the client is a caught failure in staging rather than a silent widening of the bucket.

Step 3 — Make the client request match the signature

CORS and signing are separate gates, but they fail together in a way that reads as one bug. A presigned PUT signs a specific Content-Type. If the browser sends anything else, S3 answers 403 SignatureDoesNotMatch — after a perfectly successful preflight. The classic cause is passing a Blob with no type and letting fetch decide, or letting a library append ; charset=UTF-8.

// upload.ts
export type UploadResult = { etag: string; requestId: string | null };

export async function putToBucket(
  signedUrl: string,
  file: File,
  signedContentType: string,
  signal?: AbortSignal,
): Promise<UploadResult> {
  const response = await fetch(signedUrl, {
    method: "PUT",
    // Never "include": cookies force a specific-origin echo and forbid wildcards.
    credentials: "omit",
    // Must byte-for-byte equal the Content-Type used when the URL was signed.
    headers: { "Content-Type": signedContentType },
    body: file,
    signal,
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`upload failed ${response.status}: ${body.slice(0, 300)}`);
  }

  const etag = response.headers.get("ETag");
  if (etag === null) {
    throw new Error("ETag unreadable — add ETag to ExposeHeaders on the bucket");
  }
  return { etag, requestId: response.headers.get("x-amz-request-id") };
}

The explicit null check on ETag is worth keeping permanently. It converts a configuration mistake that would otherwise surface hours later as a corrupt multipart completion into an immediate, named error. Cancellation via the signal parameter follows the pattern in aborting uploads with AbortController and timeouts.

Step 4 — Detect configuration drift in CI

Bucket CORS is the classic thing someone edits in the console at 02:00 during an incident and never puts back. A twenty-line drift check in your deploy pipeline turns that into a failed build instead of a Monday morning outage.

// check-cors-drift.ts — exits non-zero when the live bucket has diverged.
import { S3Client, GetBucketCorsCommand, type CORSRule } from "@aws-sdk/client-s3";
import { uploadCorsRules } from "./apply-cors.js";

const s3 = new S3Client({ region: process.env.AWS_REGION ?? "eu-west-1" });

function normalise(rules: CORSRule[]): string {
  return JSON.stringify(
    rules.map((r) => ({
      id: r.ID ?? "",
      origins: [...(r.AllowedOrigins ?? [])].sort(),
      methods: [...(r.AllowedMethods ?? [])].sort(),
      headers: [...(r.AllowedHeaders ?? [])].map((h) => h.toLowerCase()).sort(),
      expose: [...(r.ExposeHeaders ?? [])].map((h) => h.toLowerCase()).sort(),
      maxAge: r.MaxAgeSeconds ?? 0,
    })),
  );
}

const bucket = process.env.UPLOAD_BUCKET;
if (!bucket) throw new Error("UPLOAD_BUCKET is required");

const live = await s3.send(new GetBucketCorsCommand({ Bucket: bucket }));
const expected = normalise(uploadCorsRules("production"));
const actual = normalise(live.CORSRules ?? []);

if (expected !== actual) {
  console.error("CORS drift detected on", bucket);
  console.error("expected:", expected);
  console.error("actual:  ", actual);
  process.exit(1);
}
console.log("CORS matches source of truth on", bucket);

Expected output on a healthy bucket is a single line, CORS matches source of truth on media-uploads-prod, and exit code 0. Normalising by lowercasing and sorting stops a harmless reordering by the S3 API from failing the build.

Step 5 — Apply the same shape to GCS and Azure

The five concepts survive the move; the field names and the deployment mechanism do not. GCS folds allowed request headers into responseHeader and takes a bucket-level JSON array. Azure sets CORS on the whole Blob service rather than per container, uses comma-delimited strings instead of arrays, and — unlike S3 — requires OPTIONS to be present in allowedMethods. The full documents, the CLI invocations and the provider-specific error strings are in configuring CORS for GCS and Azure Blob uploads; the broader question of which provider to target is covered in direct-to-cloud upload patterns.

Configuration reference

Field (S3 / GCS / Azure) Type Default Effect
AllowedOrigins / origin / allowedOrigins string[] (Azure: CSV string) none Origins permitted to send the request, matched exactly. S3 allows one * per entry; GCS and Azure only as the whole value.
AllowedMethods / method / allowedMethods string[] (Azure: CSV string) none Verbs allowed. S3 rejects OPTIONS; Azure requires it. Valid S3 values are GET, PUT, HEAD, POST, DELETE.
AllowedHeaders / (folded into responseHeader) / allowedHeaders string[] (Azure: CSV string) safelist only Non-safelisted request headers the browser may send. Case-insensitive; S3 allows one * per entry.
ExposeHeaders / responseHeader / exposedHeaders string[] (Azure: CSV string) 7 safelisted response headers Response headers script may read. No * on S3 — list ETag explicitly.
MaxAgeSeconds / maxAgeSeconds / maxAgeInSeconds number header omitted; Chromium then caches 5 s Seconds the preflight verdict is cached. 3000 sits under every engine cap except Safari’s.
ID / — / — string, ≤255 chars none Optional S3 rule name. Set it: it makes drift diffs and CloudTrail entries readable.

S3 accepts up to 100 rules per bucket and a CORS document of 64 KB. Rules are evaluated in array order and the first match wins, which is the single most misunderstood behaviour in the whole feature.

Which CORS field is consulted in which phase A matrix showing that AllowedOrigins, AllowedMethods, AllowedHeaders and MaxAgeSeconds act during the preflight, while ExposeHeaders only acts on the actual upload response. Each field acts in exactly one phase CORS field OPTIONS preflight Actual PUT AllowedOrigins one wildcard char max matched and echoed back echoed again AllowedMethods no OPTIONS on S3 must list the real verb not re-checked AllowedHeaders case-insensitive must cover every header ignored ExposeHeaders no wildcard on S3 no effect on the verdict unlocks ETag for script MaxAgeSeconds clamped per engine sets Access-Control-Max-Age no effect
ExposeHeaders is the only field that does nothing during the preflight, which is why a null ETag survives an otherwise green configuration.

Edge cases and gotchas

First match wins, and a broad rule shadows a narrow one

S3 walks the rules in order and stops at the first whose origin, method and headers all match. Put a permissive GET/HEAD playback rule with AllowedOrigins: ["*"] at index 0 and it will absorb every preflight whose method it covers, never reaching your carefully scoped upload rule. Order write rules first, or scope the read rule’s origins so it cannot shadow. When no rule matches, S3 returns 403 with <Code>AccessForbidden</Code> and the message CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec. — the misspelling of “evalution” is S3’s, and grepping for it is a fast way to confirm you are looking at a CORS rejection rather than a signature failure. A bucket with no CORS document at all says CORSResponse: CORS is not enabled for this bucket. instead.

S3 rule evaluation order for a preflight S3 tests each CORS rule in array order, skipping rules whose origin or method does not match, stopping at the first full match, and returning 403 AccessForbidden if none match. OPTIONS from app.example.com Request-Method: PUT Rule 1 AllowedOrigins https://staging.example.com Origin mismatch skipped, no headers emitted Rule 2 AllowedMethods GET and HEAD only Verb PUT not listed skipped, evaluation continues Rule 3 origin, PUT, headers content-type, x-amz-checksum-crc32 First match wins, 200 Allow-Origin, Methods, Headers Fall through, 403 AccessForbidden CORSResponse: this request is not allowed
Evaluation stops at the first rule that matches all three inputs, so a broad early rule can hide a correct later one.

S3 rejects OPTIONS as an AllowedMethod

Every guide to Azure CORS tells you to include OPTIONS, and copying that habit into an S3 document produces an immediate API failure rather than a runtime one: Found unsupported HTTP method in CORS config. Unsupported method is OPTIONS. S3 answers the preflight itself and does not model OPTIONS as something you grant. The valid values are GET, PUT, HEAD, POST and DELETE, and nothing else.

Redirects are fatal to a preflight

Address a bucket through the global endpoint https://media.s3.amazonaws.com when it lives in eu-west-1 and S3 answers with 307 Temporary Redirect to the regional host. A browser will happily follow a redirect on the real request, but the Fetch specification forbids following one on a preflight. Chromium prints Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request. The fix is not a CORS rule; it is signing against the regional endpoint so the first request lands on the right host. This is also why a freshly created bucket can fail for the first few minutes while DNS for the virtual-hosted name propagates.

Credentials mode forbids wildcards

If your client sets credentials: "include", the browser requires Access-Control-Allow-Origin to name a specific origin and requires Access-Control-Allow-Credentials: true. A wildcard triggers The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. Direct uploads carry their authority in the signature, so set credentials: "omit" explicitly. It keeps session cookies off the storage host, which is a real exfiltration path if that host is ever shared with untrusted content.

A hidden ETag breaks multipart completion, not the upload

The part uploads, S3 stores it, the response is 200, and response.headers.get("ETag") still returns null because ETag was not exposed. The failure surfaces one step later, when CompleteMultipartUpload receives an empty ETag for part 3 and rejects the whole object with InvalidPart. You have then burned the bandwidth of a full upload for nothing. Expose ETag on any bucket that serves a chunked flow — see multipart versus single-PUT for files under 100 MB for when you are exposed to this at all, and resumable upload state machines for the state you must keep alongside those ETags.

CDNs cache the CORS answer along with the body

Put CloudFront in front of the bucket and the response — including Access-Control-Allow-Origin — becomes cacheable. If Origin is not part of the cache key, the first requester’s allowed origin is served to everyone, and a second origin gets a header naming the first. S3 emits Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method precisely so intermediaries know this, but a CDN with an aggressive cache policy can be configured to ignore it. Forward all three headers in the origin request policy and add OPTIONS to the distribution’s allowed methods, otherwise the preflight never reaches S3 and CloudFront answers 403 on its own.

Private Network Access adds a second preflight

Running MinIO or LocalStack on http://localhost:9000 while the page is served over HTTPS triggers Chromium’s Private Network Access check. The preflight gains Access-Control-Request-Private-Network: true and now needs Access-Control-Allow-Private-Network: true in the response — a header no object store emits by default. Symptoms are development-only failures that vanish in staging. Serve the local page over plain HTTP so both ends share a network class, or put a small proxy in front of MinIO that adds the header.

An origin of null is not a wildcard

Sandboxed iframes, documents opened from file://, and some cross-origin redirect chains send the literal string Origin: null. It does not match * in every implementation, and adding "null" to AllowedOrigins is a genuinely dangerous move: any sandboxed page anywhere becomes an allowed origin. If uploads must work from a sandboxed iframe, give it allow-same-origin so it has a real origin to send.

Preflight caching makes a fix look like a failure

After you correct a rule, the browser may keep serving the old verdict for the remainder of MaxAgeSeconds. In Chromium, DevTools’ “Disable cache” checkbox does not clear the preflight cache; you need chrome://net-internals/#socketsFlush socket pools, a fresh incognito window, or simply curl. Measure the bucket, not the browser.

The retry that never happens

Because a blocked preflight produces no response, generic retry wrappers treat it as a network blip and retry it — forever, on a schedule, against a rule that will never match. Classify TypeError: Failed to fetch with zero elapsed response headers as non-retryable in your error taxonomy, the same way you would treat a 413; see handling 413 and 507 errors during uploads for the shape of that classification.

Verification

Replay the preflight with curl

curl sends exactly what you tell it and caches nothing, which makes it the ground truth. Send the same three headers the browser would.

curl -sS -i -X OPTIONS \
  "https://media-uploads-prod.s3.eu-west-1.amazonaws.com/uploads/clip.mp4" \
  -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 like this:

HTTP/1.1 200 OK
x-amz-request-id: T9K4B2QJ7ZC3XR1D
x-amz-id-2: 5nS0oQ2vTgqf0Xk1u8oQ9pR2hV5wY7mA
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: content-type, x-amz-checksum-crc32
Access-Control-Expose-Headers: ETag, x-amz-checksum-crc32
Access-Control-Max-Age: 3000
Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
Content-Length: 0

Read it as a checklist. Access-Control-Allow-Origin absent means no rule matched. Access-Control-Allow-Headers shorter than what you requested means the real request will still be blocked even though the status is 200. No Access-Control-Max-Age means you forgot MaxAgeSeconds and are paying a preflight roughly every five seconds. For the full mapping of console errors to fixes, read fixing CORS preflight errors on S3 uploads.

Assert the contract in a test

A curl check verifies today. A test verifies every deploy. This one runs on node --test with no dependencies beyond the platform, and it fails loudly when someone narrows a rule.

// test/cors.test.js — run with: node --test
import { test } from "node:test";
import assert from "node:assert/strict";

const KEY_URL = `https://${process.env.UPLOAD_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/uploads/probe.bin`;
const ORIGIN = "https://app.example.com";

async function preflight(method, requestHeaders) {
  return fetch(KEY_URL, {
    method: "OPTIONS",
    headers: {
      Origin: ORIGIN,
      "Access-Control-Request-Method": method,
      "Access-Control-Request-Headers": requestHeaders,
    },
  });
}

test("PUT preflight is allowed for the app origin", async () => {
  const res = await preflight("PUT", "content-type,x-amz-checksum-crc32");
  assert.equal(res.status, 200);
  assert.equal(res.headers.get("access-control-allow-origin"), ORIGIN);
  const allowed = (res.headers.get("access-control-allow-headers") ?? "").toLowerCase();
  assert.ok(allowed.includes("content-type"));
  assert.ok(allowed.includes("x-amz-checksum-crc32"));
  assert.ok(Number(res.headers.get("access-control-max-age")) >= 600);
});

test("ETag is exposed so multipart completion can read it", async () => {
  const res = await preflight("PUT", "content-type");
  const exposed = (res.headers.get("access-control-expose-headers") ?? "").toLowerCase();
  assert.ok(exposed.includes("etag"), "add ETag to ExposeHeaders");
});

test("an unknown origin is rejected", async () => {
  const res = await fetch(KEY_URL, {
    method: "OPTIONS",
    headers: {
      Origin: "https://evil.example.net",
      "Access-Control-Request-Method": "PUT",
    },
  });
  assert.equal(res.status, 403);
  assert.equal(res.headers.get("access-control-allow-origin"), null);
});

The third test is the one that matters at review time. A configuration that only proves the happy path cannot detect the day someone pastes "*" into AllowedOrigins to unblock themselves.

Confirm in DevTools

Open the Network tab, clear the filter, and look for the OPTIONS row immediately above your PUT. If it is absent on the second upload, the cache is working. If the PUT row shows (failed) net::ERR_FAILED with a duration under 5 ms and no response headers, the browser blocked it locally and no request reached the network — check the OPTIONS row’s response headers, not the PUT. Loading the same upload code through the modern Fetch API for uploads does not change any of this; the enforcement lives in the browser’s network stack, below every library.

Frequently Asked Questions

Why does my upload fail with no HTTP status code in the console?

A failed preflight is blocked inside the browser before the real request is dispatched, so there is no response to attach a status to and nothing arrives in your server logs. Open the Network tab, find the OPTIONS entry, and inspect its response headers — that is where the real verdict lives.

Do I need CORS if I upload through my own API server instead of the browser?

Only for the request that leaves the browser. Your API needs CORS headers if it lives on a different origin from the page; the server-to-server leg from your API to the bucket never preflights, because CORS is enforced only by browsers. The trade-off between the two topologies is covered in presigned URL vs server proxy tradeoffs.

Is a wildcard origin actually a security hole?

Not by itself — a signature is still required to write anything. What it costs you is the ability to use credentialed requests at all, and it means a signed URL leaked into a log or a referrer header can be replayed from any page on the internet rather than only from your own. Enumerate real origins; the list is short and it rarely changes.

Can I set CORS per prefix rather than per bucket?

No provider supports path-scoped CORS rules. S3 matches on origin, method and headers only, so /uploads/* and /public/* are indistinguishable to the rule engine. If two prefixes need genuinely different policies, use two buckets, which also lets you give them different lifecycle rules for temporary uploads.

How many preflights should a large chunked upload cost?

One per distinct URL, per origin, per credentials mode, until the cache entry expires. Because each part of an S3 multipart upload carries a different partNumber query string, each part is a different URL and each pays its own preflight — around 60–90 ms on a typical connection. Raising MaxAgeSeconds does not help there; using fewer, larger parts does.