Skip to main content

CORS rules

Cross-origin resource sharing (CORS) is how a browser decides whether JavaScript on one website may talk to another. When a page on https://www.example.com uses fetch() or an S3 SDK to reach https://s3.eu-central-1.strg.com, that is a cross-origin request, and the browser lets the page send it, or read the answer, only if the bucket's CORS rules allow that page's origin. Browsers do this to protect the people using them; CORS rules are how a bucket tells the browser which websites it is happy to hear from.

A bucket needs CORS rules when a browser application talks to it directly: a web app taking uploads straight from the browser with presigned URLs, a page reading JSON or images into a script, a photo editor drawing objects onto a canvas, or an S3 SDK running in the page. Plain <img>, <video> and download links work without any, and so does everything your servers, scripts and command-line tools do - CORS is a browser matter only.

CORS grants no access by itself. A private bucket stays private: every request to it still needs a signature or a presigned URL. The rules only decide which websites' pages the browser lets send those requests.

What a browser gets​

The S3 endpoint applies a bucket's rules to every request from a web page - signed requests, presigned URLs and anonymous reads of a bucket whose Bucket Protection is off alike - and to error answers as well as successful ones, so a page's script can read why something went wrong.

Here is what each kind of browser request receives:

The browser sendsThe S3 endpoint answers
A preflight (OPTIONS) a rule allows200, with the Access-Control-* headers the rule grants - Access-Control-Max-Age from its MaxAgeSeconds
A preflight no rule allows - another origin, method or request header - or for a bucket that does not exist or is in another region403 AccessForbidden, with no Access-Control-* headers. The answer does not say which
A request from an origin a rule allowsThe answer, carrying the headers the rule grants: Access-Control-Allow-Origin, Access-Control-Allow-Credentials: true, and Access-Control-Expose-Headers with the rule's ExposeHeaders
A request from an origin no rule allowsThe answer, with no Access-Control-* headers - the browser keeps it from the page's script

A change to the rules takes a little while to reach every browser. For a short time, a page may still get the previous verdict:

AnswerPrevious verdict for up to
A preflightThe rule's MaxAgeSeconds - the browser reuses its answer that long. STRG asks your rules afresh on every preflight
An anonymous read of a bucket whose Bucket Protection is off, for an object whose Cache-Control lets a shared cache keep it5 minutes. STRG caches such answers, their Access-Control-* headers included, for at most 5 minutes, and never clears that cache early
An error answer, and the answer to an operation STRG answers itself, such as CreateBucket, DeleteBucket or GetBucketLocation1 minute
Any other answer to a signed or presigned requestNo delay - it is not cached
note

The object browser in the dashboard uploads and downloads whatever your rules say, so https://app.strg.com never needs a rule of its own.

The rule format​

STRG uses S3's JSON format for CORS rules - the one the AWS console and documentation use - so an existing AWS configuration pastes in as it is. A configuration is a list of up to 100 rules. When a browser asks, the first rule that matches the page's origin and the request is the one that applies.

cors.json
[
{
"AllowedOrigins": ["https://www.example.com"],
"AllowedMethods": ["GET", "HEAD", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3600
}
]

Each rule is made of these fields:

FieldRequiredWhat it meansAccepted values
AllowedOriginsYes, at least oneWhich websites' pages may make the request* for every website, alone in its list; https for every HTTPS website; or scheme://host[:port] with http or https, no path and no trailing slash. The host may be *, or start with one *. label, as in https://*.example.com
AllowedMethodsYes, at least oneWhat those pages may doGET, HEAD, PUT, POST, DELETE - in capitals. GET and HEAD read, PUT uploads, POST starts and completes multipart uploads, DELETE deletes
AllowedHeadersNoWhich request headers their scripts may sendHeader names. * allows any, alone in its list; a trailing * matches a prefix, as in x-amz-*
ExposeHeadersNoWhich response headers their scripts may read - browsers hide the restHeader names in full, no *
MaxAgeSecondsNoHow long, in seconds, a browser may reuse the answer before asking againA whole number from 0 to 86,400 (one day). Without it, the answer is good for 5 seconds
IDNoWelcome, so that AWS configurations paste in, and set aside rather than storedAny text

Any other field is an error, so a misspelt field name shows up right away rather than being quietly ignored.

What is stored​

STRG stores your rules in a tidy, normalized form, and that form is what the dashboard and GetBucketCors return:

You sendStored and returned
IDRemoved
Origins and header names in any caseLowercased
Methods in any orderPossibly reordered - the dashboard shows GET, HEAD, PUT, POST, DELETE; over the S3 API the order is not guaranteed, so methods are best compared as a set
An empty AllowedHeaders or ExposeHeaders listOmitted
No MaxAgeSecondsOmitted

Code that checks what it saved does best to compare against the normalized form, rather than against what it sent.

Limits and checks​

STRG checks each rule as it is saved, so a typo shows up right away rather than as a puzzling browser error later. These are the limits a configuration lives within:

LimitValue
Rules per bucket100
MaxAgeSeconds0-86,400
Size of one rule1,000 bytes, counted as below
Origins per rule* is the only origin when it is used; https cannot be listed beside any https:// origin
* in an originAt most one, and only as the whole host or its first label
* in an allowed headerOnly as the last character, and a bare * is the only header when it is used
DuplicatesAn origin, method or header listed twice in the same rule is an error. Origins and headers compare case-insensitively

The rule size budget​

Each rule has a budget of 1,000 bytes. Its size is the sum of the UTF-8 bytes of:

PartBytes
A fixed overhead per rule25 for rules 1-9, 26 for rules 10-99, 27 for rule 100
Each originIts length
Each methodGET 6, HEAD 7, PUT 6, POST 7, DELETE 9
Each allowed header and each exposed headerIts length
MaxAgeSeconds0

A rule of up to 1,000 bytes is accepted; one byte more is not. A rule that grows past the budget splits neatly into two rules with the same methods and half the origins each.

Setting rules in the dashboard​

A bucket's CORS rules live with its settings in the dashboard - the Settings tab of the bucket's page, or the settings column beside the objects on a wide screen. A change goes like this:

  1. On the CORS rules card, the Edit button opens the Edit CORS rules dialog on the rules the bucket holds now.
  2. The JSON can be edited in place, a whole configuration pasted in, or a predefined rule added - Presets describes them. The editor marks every problem in place, naming the rule and field it concerns, before anything is saved.
  3. Save rules stores the result.

Saving replaces every rule on the bucket, and saving an empty list, [], removes them all. When a rule lets every origin (*) use PUT, POST or DELETE, the dialog points it out: that is allowed, but rarely what is meant, and naming your own site is usually the safer choice.

Presets​

Add a predefined rule builds one rule from a few answers, for the situations that come up most often. Picking a preset, filling in what it asks for and pressing Add rule appends the rule after your other rules; nothing is saved until Save rules. On a bucket whose Bucket Protection is off, Allow reads from any website is already selected when the dialog opens.

PresetAsks forBuilds
Allow reads from any websiteNothingAllowedOrigins *; GET, HEAD; allowed header range; exposes etag, content-length, content-range, accept-ranges; 3,600 seconds
Allow reads from my websiteYour website's originThe same, for your origins only
Browser uploads from my websiteYour website's originYour origins; GET, HEAD, PUT; any header; exposes etag; 3,600 seconds
Full access for a web appYour website's originYour origins; GET, HEAD, PUT, POST, DELETE; any header; exposes etag, x-amz-version-id, x-amz-request-id; 3,600 seconds
Local developmentDevelopment server ports - 3000, 5173 to start withhttp://localhost:<port> for each port; GET, HEAD, PUT, POST; any header; exposes etag; 600 seconds

Where a preset asks for your website's origin, it wants the scheme, host and - if it is not the default - the port, with no path. Several origins can be separated with commas, and https://*.example.com covers every subdomain.

Setting rules with the S3 API​

Any S3 tool can manage the same rules: PutBucketCors sets a bucket's rules, GetBucketCors reads them and DeleteBucketCors removes them. The access key making the call needs:

OperationAccess key needs
GetBucketCorsNothing beyond what every key holds (Read bucket settings)
PutBucketCors, DeleteBucketCorsManage buckets, on a key for all buckets. A key scoped to some buckets gets 403 AccessDenied. Access keys has the details

The AWS CLI and SDKs wrap the list of rules in a CORSRules field. This file sets one rule:

cors.json
{
"CORSRules": [
{
"AllowedOrigins": ["https://www.example.com"],
"AllowedMethods": ["GET", "HEAD", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3600
}
]
}
aws s3api put-bucket-cors \
--bucket my-bucket \
--cors-configuration file://cors.json \
--endpoint-url https://s3.eu-central-1.strg.com \
--region eu-central-1

aws s3api get-bucket-cors \
--bucket my-bucket \
--endpoint-url https://s3.eu-central-1.strg.com \
--region eu-central-1

PutBucketCors replaces every rule on the bucket, and STRG checks it against the same rules as the dashboard before anything is stored. Over the S3 API, each situation has its own answer:

SituationAnswer
A rule breaks a limit above400 InvalidRequest, naming the rule and field
A rule carries an element STRG has no field for, or a method other than the five above400 InvalidRequest
No rules at all400 MalformedXML - DeleteBucketCors is the way to remove every rule
Neither Content-MD5 nor an x-amz-checksum-* header400 InvalidRequest. The AWS CLI and SDKs send one by default
A key scoped to some buckets403 AccessDenied
GetBucketCors on a bucket without rules404 NoSuchCORSConfiguration

Examples​

Each example is a complete configuration, ready to paste into the dashboard editor. For the CLI or an SDK, the list goes inside { "CORSRules": … }.

A single-page app uploading with presigned URLs​

Your server hands out presigned PUT URLs, and the page on https://app.example.com uploads straight to the bucket and reads the files back. etag is exposed because multipart uploaders need it to complete an upload; an app that uses multipart uploads also lists POST.

[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "HEAD", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3600
}
]

Public reads from any origin​

Any web page may fetch objects with JavaScript - images drawn on a canvas, web fonts, JSON, and media players that request byte ranges. On a bucket whose Bucket Protection is off, the page reads objects by their plain URL, such as https://s3.eu-central-1.strg.com/my-bucket/photos/2024/cat.jpg, with no signature; listing the bucket still needs one. On a protected bucket, every request needs a presigned URL.

[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["range"],
"ExposeHeaders": ["etag", "content-length", "content-range", "accept-ranges"],
"MaxAgeSeconds": 3600
}
]

Production and local development side by side​

Your site and its subdomains may read and upload, and a development server on your own machine may do the same, with a shorter cache so changes show up sooner.

[
{
"AllowedOrigins": ["https://example.com", "https://*.example.com"],
"AllowedMethods": ["GET", "HEAD", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3600
},
{
"AllowedOrigins": ["http://localhost:3000", "http://localhost:5173"],
"AllowedMethods": ["GET", "HEAD", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 600
}
]
View as Markdown