skip to content

A browser PUT to an S3 presigned URL fails with a CORS error in the developer console, yet the same URL works from curl. What is actually wrong, and what do you configure to fix it?

level: middleimportance: nice to knowfreq 40%

answer

  1. curl has no CORS rules
  2. the preflight is unsigned and anonymous
  3. origin match includes scheme and port
  4. list every header you actually send
  5. ETag needs explicit exposure

basics

~20 s

Nothing is wrong with the signature — curl proves that. The bucket has no CORS configuration permitting your page's origin and the PUT method, so the browser blocks the cross-origin request. Fix it by putting a CORS configuration on the bucket.

solid answer

~40 s

CORS is a browser rule, not an S3 permission, which is why curl succeeds and the page does not. A cross-origin `PUT` carrying a `Content-Type` the browser considers non-simple triggers a preflight `OPTIONS` to the bucket; with no CORS configuration S3 answers without the required headers and the browser refuses to send the real request. The fix is a CORS configuration on the bucket listing your exact origin — scheme, host and port all matter — the methods you use, the request headers you send in `AllowedHeaders`, and anything JavaScript must read back in `ExposeHeaders`, `ETag` being the usual one. Note that the preflight is unsigned and anonymous, so a signature problem cannot be the cause; and a permissive CORS configuration grants nothing on its own — authorization still comes from the presigned signature.

code

json · 9 lines
json
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["content-type", "x-amz-*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

go deeper

for a junior

Know that browsers enforce CORS and S3 does not, so a bucket needs a CORS configuration listing your site's origin before a page can upload to a presigned URL.

for a middle

Explain the unsigned preflight OPTIONS, what each CORS field controls, and why a missing AllowedHeaders entry for content-type fails in a way that looks like an origin problem.

for a senior

Diagnose from the network tab — preflight versus real request — and separate CORS failures from signed-header mismatches and expiry, rather than widening the configuration until something works.

for a principal

Own the standard: exact origins per environment rather than wildcards on upload buckets, and a shared upload client so every team is not rediscovering preflight and ExposeHeaders on its own.

## Why curl works and the browser does not CORS has no bearing on whether S3 will accept a request. It is a rule enforced *by the browser*, protecting one origin's script from silently reading another origin's responses. `curl` implements no such rule, so a presigned URL that works there proves the signature, the expiry and the signing principal's permissions are all fine. When the page fails anyway, the missing piece is the bucket's CORS configuration. ## The preflight A cross-origin `fetch` with method `PUT`, or with a `Content-Type` outside the small "simple" set, is not sent directly. The browser first issues: ``` OPTIONS /uploads/abc123.png HTTP/1.1 Origin: https://app.example.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: content-type ``` Two things about that request surprise people. It carries **no signature** — the query-string authentication is not part of a preflight — and it is anonymous. So S3 answers it purely from the bucket's CORS configuration; policy and signature play no part. If no rule matches the `Origin` and the requested method, the response lacks `Access-Control-Allow-Origin`, and the browser reports a CORS failure and never sends the PUT. The console message is about CORS, but the network tab shows an `OPTIONS`, not the upload — that is the diagnostic. ## The configuration CORS lives on the bucket as a small JSON document of rules: ```json [ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT", "GET", "HEAD"], "AllowedHeaders": ["content-type", "x-amz-*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ] ``` Field by field: - **AllowedOrigins** must match the page's origin exactly: scheme, host and port. `https://app.example.com` does not cover `http://app.example.com`, `https://www.app.example.com`, or `http://localhost:3000` during development. A wildcard is accepted but is a poor idea for an upload bucket. - **AllowedMethods** is a fixed vocabulary of HTTP methods; `PUT` for uploads, `GET`/`HEAD` for reads, `POST` for policy-form uploads, `DELETE` if the client deletes. - **AllowedHeaders** must list every header your code sets on the request. Forgetting `content-type` is the single most common cause of a preflight rejection that looks like an origin problem. - **ExposeHeaders** controls what JavaScript may *read* from the response. By default a cross-origin response exposes almost nothing, so `response.headers.get('ETag')` returns `null` unless `ETag` is listed here — which matters when your code needs the returned entity tag after an upload. - **MaxAgeSeconds** lets the browser cache the preflight result, so you are not paying an extra round trip per upload. ## Two things it is not **CORS is not authorization.** A wide-open CORS configuration does not make a private bucket readable; every request still needs a valid signature or a policy that allows it. Conversely, tightening CORS is not a security control against non-browser clients — it does nothing to curl, a script, or a mobile app. **CORS is not the answer to every browser-side 403.** If the network tab shows the actual `PUT` reaching S3 and returning 403, CORS is not your problem — you are looking at a signature mismatch, an expired URL or a permissions failure. A useful discriminator: a genuine CORS block means the real request was never sent. ## The other classic near-miss Adjacent to CORS, and often mistaken for it, is a signed-header mismatch. If you presigned with `Content-Type` among the signed headers, the browser must send exactly that value; a `fetch` that omits it, or lets the browser infer a different one from a `Blob`, produces `SignatureDoesNotMatch`. That failure returns a real HTTP response from S3, so with CORS configured you will see a 403 in the network tab rather than a CORS message — which is precisely how you tell the two apart. ## In an interview Say it in one line: "CORS is enforced by the browser, so curl succeeding tells me the S3 side is fine. I would add a bucket CORS rule for the exact origin, allow PUT, list the request headers I actually send, and expose ETag if my code reads it."

  • How do you tell a real CORS block from a 403 in the browser's network tab?
    Look at which request appears. A CORS block means the browser never sent the real request — you see an `OPTIONS` and then a console error with no `PUT` at all. A 403 means the `PUT` was sent and S3 answered, so the cause is a signature mismatch, an expired URL or a permissions problem, and the CORS configuration is already adequate.
  • Does adding a permissive CORS configuration weaken the bucket's security?
    Not directly — CORS grants no access. Every request still needs a valid signature or an allowing policy, and non-browser clients ignore CORS entirely. What a wildcard origin does allow is any web page to *use* credentials or presigned URLs it has obtained from within its own JavaScript, so it removes a layer of containment even though it opens no data on its own.
  • Why does response.headers.get('ETag') return null after a successful cross-origin upload?
    Cross-origin responses expose only a small default set of headers to script. `ETag` is not among them, so it must appear in the bucket's `ExposeHeaders` before JavaScript can read it. The upload itself succeeded — this is purely a browser visibility rule, not a sign that S3 withheld the header.

saying these in an interview costs you the question

  • Thinks a CORS error means the presigned signature is invalid
  • Believes an open CORS configuration grants access to the bucket
  • Assumes the preflight OPTIONS carries the presigned signature
  • Sets AllowedOrigins to the domain but forgets the scheme or port
  • Expects JavaScript to read ETag without listing it in ExposeHeaders

context