Skip to main content

Configure CORS for a SPA

If your single-page app at https://app.example.com needs to fetch from or upload to a bucket at your-bucket.s3.filebase.io, you need CORS. Without it, the browser blocks the cross-origin request.

This is a complete recipe — bucket CORS configuration, the application-side fetch, and how to debug when it fails.

Step 1 — Decide what your SPA needs

Pick the smallest set of methods and origins that gets the job done:

  • Reading from a public bucket → GET, HEAD.
  • Uploading via pre-signed PUTPUT, plus GET/HEAD if you also read.
  • Uploading via pre-signed POST → POST, plus GET/HEAD.
  • Multipart browser upload (e.g. Uppy) → PUT, POST, GET, HEAD.

Step 2 — Write the CORS rule

cors.json
{
"CORSRules": [
{
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedOrigins": [
"https://app.example.com",
"http://localhost:5173"
],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-request-id"],
"MaxAgeSeconds": 3000
}
]
}

What each field does:

  • AllowedMethods — the HTTP methods the browser is allowed to make cross-origin. Must include every method your code uses.
  • AllowedOrigins — the origins permitted to make cross-origin requests. Add localhost for local dev. Avoid * for application buckets.
  • AllowedHeaders — request headers the browser may send. * is convenient; for stricter security, list explicitly: ["Authorization", "Content-Type", "x-amz-content-sha256", "x-amz-date"].
  • ExposeHeaders — response headers that your JavaScript can read (ETag matters for many upload UX patterns).
  • MaxAgeSeconds — how long the browser caches the preflight response. 3000 (50 min) is a reasonable default.

Step 3 — Apply the rule

aws --endpoint https://s3.filebase.io s3api put-bucket-cors \
--bucket your-bucket \
--cors-configuration file://cors.json

Verify:

aws --endpoint https://s3.filebase.io s3api get-bucket-cors --bucket your-bucket

Step 4 — SPA-side fetch

If reading directly from a public bucket:

const res = await fetch(
'https://your-bucket.s3.filebase.io/data/app.json',
);
const data = await res.json();

If using a pre-signed PUT to upload:

const { url } = await fetch('/api/sign-upload', { ... }).then(r => r.json());

await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});

Debugging

When CORS fails, the browser reports specifics in the console. Common failures:

"Method not allowed"

Your CORS rule doesn't list the method you're using. Add PUT (or POST, etc.) to AllowedMethods.

"Origin not allowed"

The origin (scheme + host + port) doesn't match anything in AllowedOrigins. Check carefully:

  • http://localhost:5173 is not the same as http://localhost:3000.
  • https://example.com is not the same as https://www.example.com.

"Header not allowed in preflight"

Your request sets a header (commonly x-amz-... headers from a signed request, or a custom auth header) that's not in AllowedHeaders. Add it, or use ["*"].

Preflight succeeds but the actual request fails

Your AllowedHeaders is correct for OPTIONS but the actual request includes a different set. Use ["*"] to cover both directions.

Inspecting preflight by hand

Simulate the browser's preflight with curl:

curl -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type" \
-i \
https://your-bucket.s3.filebase.io/test

The response's Access-Control-Allow-* headers tell you what's permitted. Compare against what the browser actually sent.

Step 5 — Lock down for production

When you ship to production, narrow AllowedOrigins to your real domains (drop the localhost entry). Keep the dev origin in a separate dev/staging bucket if you want to keep your config tight.

What's next