Skip to content

Fix: AWS S3 CORS Error — Access to Fetch Blocked by CORS Policy

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix AWS S3 CORS errors — S3 bucket CORS configuration, pre-signed URL CORS, CloudFront CORS headers, OPTIONS preflight requests, and presigned POST uploads.

The Error

The browser blocks a request to an S3 bucket:

Access to fetch at 'https://mybucket.s3.amazonaws.com/image.jpg'
from origin 'https://myapp.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Or a file upload using a pre-signed URL fails:

Access to XMLHttpRequest at 'https://mybucket.s3.amazonaws.com/uploads/...'
from origin 'https://myapp.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.

Or CORS works for GET requests but fails for PUT (file upload):

Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.

Why This Happens

S3 buckets don’t serve CORS headers by default. Browsers enforce the same-origin policy: a page loaded from https://myapp.example.com cannot make JavaScript requests to https://mybucket.s3.amazonaws.com unless S3 explicitly responds with Access-Control-Allow-Origin headers. For requests that modify data (PUT, POST, DELETE) or include custom headers, the browser sends a preflight OPTIONS request first. If S3 doesn’t respond with the correct CORS headers on that preflight, the browser blocks the actual request before it ever reaches S3.

The confusion multiplies because S3 CORS is separate from every other AWS access control mechanism. A bucket policy controls which AWS principals can access the bucket. ACLs control object-level access. IAM policies control what API calls a user or role can make. None of these produce CORS headers. You can have a completely public bucket policy that allows anonymous GetObject, and the browser will still block the request if no CORS configuration exists on the bucket. Conversely, a perfect CORS configuration does nothing if the bucket policy denies the request — the request fails with a 403 before CORS headers are evaluated.

CloudFront adds another layer. When you serve S3 content through a CloudFront distribution, CloudFront must forward the Origin request header to S3 (so S3 knows to return CORS headers) and pass S3’s CORS response headers back to the browser. By default, CloudFront does not forward the Origin header, which means S3 never sees it and never returns CORS headers. The request works fine when hitting S3 directly but fails through CloudFront.

Fix 1: Add CORS Configuration to the S3 Bucket

Configure CORS on the S3 bucket via the AWS console or CLI:

AWS Console:

  1. Open S3 → select your bucket
  2. Permissions tab → Cross-origin resource sharing (CORS)Edit
  3. Paste the JSON configuration below → Save changes

JSON CORS configuration (permissive — for development):

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag", "x-amz-request-id"],
    "MaxAgeSeconds": 3000
  }
]

Production CORS configuration (restrict to specific origins):

[
  {
    "AllowedHeaders": [
      "Content-Type",
      "Authorization",
      "x-amz-date",
      "x-amz-content-sha256",
      "x-amz-security-token"
    ],
    "AllowedMethods": ["GET", "PUT", "POST"],
    "AllowedOrigins": [
      "https://myapp.example.com",
      "https://staging.example.com"
    ],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Apply via AWS CLI:

# Set CORS configuration
aws s3api put-bucket-cors \
  --bucket mybucket \
  --cors-configuration file://cors.json

# Verify
aws s3api get-bucket-cors --bucket mybucket

# cors.json example:
cat > cors.json << 'EOF'
{
  "CORSRules": [
    {
      "AllowedHeaders": ["*"],
      "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
      "AllowedOrigins": ["https://myapp.example.com"],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3000
    }
  ]
}
EOF

Fix 2: Fix Pre-signed URL CORS for File Uploads

Pre-signed URL uploads (direct browser-to-S3) require specific CORS configuration and careful header handling:

// Backend — generate a pre-signed URL for PUT upload
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'us-east-1' });

async function getUploadUrl(key: string, contentType: string): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    ContentType: contentType,
    // ACL: 'public-read',  // Only if using ACLs (Object Ownership must allow)
  });

  return getSignedUrl(s3, command, { expiresIn: 3600 });
}
// Frontend — upload using the pre-signed URL
async function uploadFile(file, uploadUrl) {
  const response = await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type,   // Must match what was signed
    },
    // Do NOT set Authorization header — it's embedded in the URL
  });

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`);
  }
}

S3 CORS config for pre-signed PUT uploads:

[
  {
    "AllowedHeaders": ["Content-Type", "Content-Length"],
    "AllowedMethods": ["PUT"],
    "AllowedOrigins": ["https://myapp.example.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Pre-signed POST for more control (multi-part, file size limits):

// Backend — generate a pre-signed POST
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';

async function getUploadPost(key: string): Promise<{url: string; fields: Record<string, string>}> {
  const { url, fields } = await createPresignedPost(s3, {
    Bucket: process.env.S3_BUCKET,
    Key: key,
    Conditions: [
      ['content-length-range', 0, 10 * 1024 * 1024],  // Max 10MB
      ['starts-with', '$Content-Type', 'image/'],
    ],
    Fields: {
      'Content-Type': 'image/jpeg',
    },
    Expires: 600,  // 10 minutes
  });
  return { url, fields };
}
// Frontend — upload with FormData (pre-signed POST)
async function uploadWithPost(file, { url, fields }) {
  const formData = new FormData();

  // Append all policy fields FIRST
  Object.entries(fields).forEach(([key, value]) => {
    formData.append(key, value);
  });

  // File must be the LAST field
  formData.append('file', file);

  const response = await fetch(url, {
    method: 'POST',
    body: formData,
    // Do NOT set Content-Type header — browser sets it with the boundary
  });

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status} ${await response.text()}`);
  }
}

CORS config for pre-signed POST:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["POST"],
    "AllowedOrigins": ["https://myapp.example.com"],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 3600
  }
]

Fix 3: S3 Static Website Hosting vs Direct Bucket Access

S3 has two URL formats, and CORS behaves differently for each:

# Direct bucket access (REST API endpoint)
https://mybucket.s3.amazonaws.com/image.jpg
https://mybucket.s3.us-east-1.amazonaws.com/image.jpg

# Static website hosting endpoint
http://mybucket.s3-website-us-east-1.amazonaws.com/image.jpg

Key difference: the static website hosting endpoint returns HTML error pages (e.g., 404.html) and supports redirects, but it does not support HTTPS on its own. It also handles CORS headers differently — the website endpoint does not respond to OPTIONS preflight requests the same way as the REST API endpoint. If you enable static website hosting and access objects through the website endpoint, you must ensure your CORS config covers the GET method, but the OPTIONS method may not produce the expected preflight response.

If using CloudFront in front of static website hosting:

# CloudFront origin must point to the website endpoint, not the REST endpoint
# Website endpoint: mybucket.s3-website-us-east-1.amazonaws.com
# REST endpoint:    mybucket.s3.amazonaws.com

# Using the REST endpoint as a CloudFront origin:
# - CloudFront uses the S3 REST API → OAI/OAC authentication works
# - CORS headers are returned from S3's CORS configuration

# Using the website endpoint as a CloudFront origin:
# - CloudFront treats it as a custom HTTP origin
# - No OAI/OAC — bucket policy must allow public access
# - CORS headers still come from S3's CORS configuration

Fix 4: Fix CloudFront CORS

If your S3 bucket is served through CloudFront, you need additional configuration. CloudFront must forward the Origin request header to S3 and pass CORS response headers to the browser:

Create a Cache Policy that forwards the Origin header:

# Option 1 — Use CloudFront's managed "CORS-S3Origin" cache policy
# Policy ID: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
# Apply it to your CloudFront distribution's behaviors

# Option 2 — Create a custom policy via AWS console or CLI
aws cloudfront create-cache-policy --cache-policy-config '{
  "Name": "CORS-Policy",
  "DefaultTTL": 86400,
  "MaxTTL": 31536000,
  "MinTTL": 0,
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "EnableAcceptEncodingGzip": true,
    "HeadersConfig": {
      "HeaderBehavior": "whitelist",
      "Headers": {
        "Quantity": 1,
        "Items": ["Origin"]
      }
    },
    "CookiesConfig": { "CookieBehavior": "none" },
    "QueryStringsConfig": { "QueryStringBehavior": "none" }
  }
}'

CloudFront Origin Request Policy — forward headers to S3:

# Create an Origin Request Policy that passes Access-Control headers
# Use managed "CORS-S3Origin" policy or create custom

In AWS Console:

  1. CloudFront → Distributions → select your distribution
  2. Behaviors tab → edit the behavior serving your S3 content
  3. Cache key and origin requests → select “Cache policy and origin request policy”
  4. Cache policy: Select CachingOptimized
  5. Origin request policy: Select CORS-S3Origin (AWS managed)

Invalidate the CloudFront cache after fixing:

aws cloudfront create-invalidation \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --paths "/*"
# Wait for invalidation to complete before testing again

Lambda@Edge for injecting CORS headers: If you cannot modify the S3 CORS config (e.g., a third-party bucket), use a Lambda@Edge function on the CloudFront distribution to add CORS headers to every response:

// Lambda@Edge — origin response trigger
exports.handler = async (event) => {
  const response = event.Records[0].cf.response;
  const request = event.Records[0].cf.request;

  const origin = request.headers['origin']
    ? request.headers['origin'][0].value
    : '';

  const allowedOrigins = [
    'https://myapp.example.com',
    'https://staging.example.com',
  ];

  if (allowedOrigins.includes(origin)) {
    response.headers['access-control-allow-origin'] = [
      { key: 'Access-Control-Allow-Origin', value: origin },
    ];
    response.headers['access-control-allow-methods'] = [
      { key: 'Access-Control-Allow-Methods', value: 'GET, PUT, POST' },
    ];
    response.headers['access-control-allow-headers'] = [
      { key: 'Access-Control-Allow-Headers', value: 'Content-Type' },
    ];
  }

  return response;
};

Fix 5: Cross-Region CORS and Regional Endpoint Quirks

S3 regional endpoints behave slightly differently from the global endpoint for CORS:

# Global endpoint (us-east-1 only)
https://mybucket.s3.amazonaws.com/file.jpg

# Regional endpoint (all regions)
https://mybucket.s3.us-west-2.amazonaws.com/file.jpg

# Path-style vs virtual-hosted-style
# Virtual-hosted (recommended): https://mybucket.s3.us-west-2.amazonaws.com/file.jpg
# Path-style (deprecated):      https://s3.us-west-2.amazonaws.com/mybucket/file.jpg

If your app is in us-east-1 and your bucket is in eu-west-1: the browser origin and S3 endpoint are on different AWS regions, but CORS doesn’t care about regions — it only cares about the Origin header matching AllowedOrigins. The more common problem is that cross-region requests have higher latency, which can cause pre-signed URLs to expire during slow uploads. Set a longer expiresIn for cross-region pre-signed URLs.

// Cross-region — use a longer expiry
const url = await getSignedUrl(s3, command, {
  expiresIn: 7200,  // 2 hours instead of 1
});

Fix 6: Debug CORS Issues

Systematic approach to diagnosing CORS problems:

# Step 1 — Send an OPTIONS preflight request manually
curl -X OPTIONS \
  "https://mybucket.s3.amazonaws.com/test-file.jpg" \
  -H "Origin: https://myapp.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Content-Type" \
  -v 2>&1 | grep -i "access-control\|HTTP/"

# Expected response headers:
# Access-Control-Allow-Origin: https://myapp.example.com
# Access-Control-Allow-Methods: GET, PUT, POST
# Access-Control-Allow-Headers: Content-Type
# Access-Control-Max-Age: 3000

# If you get no Access-Control-* headers → CORS not configured on the bucket

# Step 2 — Test a direct GET request
curl "https://mybucket.s3.amazonaws.com/test-file.jpg" \
  -H "Origin: https://myapp.example.com" \
  -v 2>&1 | grep -i "access-control"

# Step 3 — Check the bucket's CORS configuration
aws s3api get-bucket-cors --bucket mybucket
# "NoSuchCORSConfiguration" → bucket has no CORS config → add one

Browser DevTools — inspect the preflight:

  1. Open DevTools → Network tab
  2. Filter by the file or URL
  3. Look for the OPTIONS request (preflight) before the actual request
  4. Check its response headers for Access-Control-* headers
  5. If the OPTIONS request returns 403 or has no CORS headers, the bucket policy or CORS config is wrong

Fix 7: Fix CORS for Authenticated Requests

If your S3 requests include credentials (cookies or Authorization headers), AllowedOrigins: ["*"] won’t work. Browsers require an explicit origin for credentialed cross-origin requests:

// WRONG — wildcard origin with credentials
[
  {
    "AllowedOrigins": ["*"],  // Browsers reject credentialed requests with wildcard
    "AllowedMethods": ["GET"],
    "AllowedHeaders": ["Authorization"]
  }
]

// CORRECT — explicit origin for credentialed requests
[
  {
    "AllowedOrigins": ["https://myapp.example.com"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["Authorization", "Content-Type"]
  }
]
// Frontend — if using credentials in the request
fetch('https://mybucket.s3.amazonaws.com/file.jpg', {
  credentials: 'include',   // Sends cookies — requires explicit AllowedOrigins
  // Note: S3 pre-signed URLs typically don't use cookies
  // Only use credentials: 'include' if your bucket requires it
});

Fix 8: Terraform and CDK — Configure CORS Programmatically

If managing AWS infrastructure as code:

Terraform:

resource "aws_s3_bucket_cors_configuration" "uploads" {
  bucket = aws_s3_bucket.uploads.id

  cors_rule {
    allowed_headers = ["Content-Type", "Authorization"]
    allowed_methods = ["GET", "PUT", "POST"]
    allowed_origins = [
      "https://myapp.example.com",
      "https://staging.example.com",
    ]
    expose_headers  = ["ETag"]
    max_age_seconds = 3600
  }
}

AWS CDK (TypeScript):

import { Bucket } from 'aws-cdk-lib/aws-s3';
import { HttpMethods } from 'aws-cdk-lib/aws-s3';

const bucket = new Bucket(this, 'UploadsBucket', {
  cors: [
    {
      allowedHeaders: ['Content-Type', 'Authorization'],
      allowedMethods: [HttpMethods.GET, HttpMethods.PUT, HttpMethods.POST],
      allowedOrigins: ['https://myapp.example.com'],
      exposedHeaders: ['ETag'],
      maxAge: 3600,
    },
  ],
});

Still Not Working?

CORS config applied but still failing — S3 CORS changes take effect immediately, but CloudFront caches responses. Invalidate the CloudFront cache after any CORS changes.

Bucket policy blocking CORS — even with CORS configured, a bucket policy that denies the request will cause a 403 before CORS headers are evaluated. Check the bucket policy:

aws s3api get-bucket-policy --bucket mybucket
# Look for explicit Deny statements that might block your request

Object-level ACLs — if the object doesn’t have the correct ACL (or Object Ownership is set to “Bucket owner enforced”), access is denied regardless of CORS config.

ExposeHeaders for client access — response headers are only accessible to JavaScript if they’re in ExposeHeaders. If your frontend needs to read ETag or custom headers, add them:

{
  "ExposeHeaders": ["ETag", "x-amz-version-id", "x-amz-request-id"]
}

Development with localhost — for local development, include http://localhost:3000 in AllowedOrigins. Browsers treat http://localhost:3000 and https://localhost:3000 as different origins:

{
  "AllowedOrigins": [
    "https://myapp.example.com",
    "http://localhost:3000",
    "http://localhost:5173"
  ]
}

VPC endpoint (Gateway endpoint) CORS — if your Lambda or EC2 accesses S3 through a VPC gateway endpoint, CORS is irrelevant for server-to-server calls (CORS is a browser-side enforcement). But if your frontend calls an API Gateway that proxies to S3 through a VPC endpoint, CORS headers must be set on the API Gateway response, not on S3.

S3 Transfer Acceleration endpoint — if you use S3 Transfer Acceleration (mybucket.s3-accelerate.amazonaws.com), the accelerated endpoint respects the same CORS configuration as the standard endpoint. No separate CORS config is needed, but your AllowedOrigins must match the requesting origin, not the S3 endpoint URL.

For related AWS issues, see Fix: AWS IAM Permission Denied, Fix: AWS S3 Access Denied, Fix: CORS Access-Control-Allow-Origin, and Fix: CORS Preflight Request Blocked.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles