Skip to content

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

FixDevs ·

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 send a preflight OPTIONS request before cross-origin PUT, POST, or credentialed GET requests. If S3 doesn’t respond with the correct CORS headers, the browser blocks the actual request.

S3’s CORS is separate from:

  • Bucket policies — control who can access the bucket, not CORS headers
  • ACLs — control object access, not CORS headers
  • CloudFront — CloudFront has its own CORS configuration that must align with S3’s

Common mistakes:

  • Missing CORS configuration on the bucket — the most common cause
  • AllowedOrigins doesn’t match the request origin* works for non-credentialed requests; credentialed requests need an explicit origin
  • AllowedMethods missing PUT — GET-only CORS configuration blocks upload requests
  • AllowedHeaders missing custom headersContent-Type, x-amz-* headers must be explicitly allowed
  • CloudFront caching the OPTIONS response — CloudFront may cache an old response that lacks CORS headers

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: 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

Fix 4: 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 5: 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 6: 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"
  ]
}

For related AWS issues, see Fix: AWS IAM Permission Denied and Fix: GitHub Actions Docker Build Push.

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