← Back to list

Why Your Presigned S3 Upload Fails in Production — And the CloudFront Fix Nobody Talks About

A deep dive into the invisible wall of CORS and CSP that blocks direct-to-S3 uploads, and how to solve it without exposing your…

Chaos To Clarity in AWS in Plain English · 2026-05-04 09:36 · 1 claps · 5.2 min read paywalled
#cloudfront #presigned-url #aws-s3 #csp #cors
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Why Your Presigned S3 Upload Fails in Production — And the CloudFront Fix Nobody Talks About

A deep dive into the invisible wall of CORS and CSP that blocks direct-to-S3 uploads, and how to solve it without exposing your infrastructure

You’ve built everything right. The Lambda generates a presigned POST. The S3 bucket has CORS configured. The frontend sends the file. And yet — a red error in DevTools, a cryptic “Unable to upload” modal, and hours of debugging ahead.

Sound familiar? Let’s break down exactly why this happens, and explore three progressively better solutions — culminating in the architecture pattern used by teams that refuse to leak their infrastructure details to the browser.

The Setup

Imagine a typical enterprise SaaS architecture:

  • A React frontend served from https://my.app.example.com via CloudFront
  • An API layer behind https://api.app.example.com (another CloudFront distribution → API Gateway → Lambda)
  • An S3 bucket for file storage (purchase orders, PDFs, images)

The user needs to upload a file. You implement the well-documented presigned POST pattern:

  1. Frontend calls your API: POST /{id}/purchaseOrders
  2. Lambda generates a presigned POST using @aws-sdk/s3-presigned-post
  3. Lambda returns the presigned URL and form fields
  4. Frontend POSTs the file directly to S3 using those credentials
const { url, fields } = await createPresignedPost(s3Client, {
  Bucket: cfg.filesBucket,
  Key: uniqueKey,
  Expires: 900,
  Fields: {
    'Content-Type': contentType,
    'x-amz-tagging': 'ttl=temporary',
  },
  Conditions: [
    ['content-length-range', 0, 4 * 1024 * 1024],
    ['eq', '$Content-Type', contentType],
  ],
});

The url returned is something like:

https://my-bucket.s3.us-east-1.amazonaws.com

You test it locally. It works. You deploy to production. It breaks.

The Invisible Wall

Open DevTools → Network tab. You’ll see the POST to S3 is red, with “Provisional headers are shown” and no response body. The Console shows:

Refused to connect to 'https://my-bucket.s3.us-east-1.amazonaws.com' 
because it violates the following Content Security Policy directive: 
"connect-src 'self' https://api.app.example.com"

The request never left the browser.

Understanding the Two Gatekeepers

There are two independent security mechanisms that can block a cross-origin request. Both must pass for the request to succeed, and they fail in different ways:

Gatekeeper 1: Content Security Policy (CSP)

CSP is a browser-enforced allow list set via HTTP response headers (or <meta> tags) by the server that delivered the HTML page. The connect-src directive controls which domains the page's JavaScript can make network requests to.

connect-src 'self' https://auth.example.com https://api.app.example.com

If the S3 bucket domain isn’t listed → the browser refuses to even attempt the request. This happens before any network activity.

Key insight: CSP is set by the CloudFront distribution serving your frontend, not the one serving your API. It’s easy to forget this because frontend and backend teams often work in separate repos.

Gatekeeper 2: S3 CORS

If CSP allows the request, the browser sends an OPTIONS preflight to S3. S3 checks its CORS configuration:

cors_rule {
  allowed_methods = ["POST", "HEAD"]
  allowed_origins = ["https://my.app.example.com"]
  allowed_headers = ["*"]
}

If the Origin header doesn't match allowed_origins → S3 returns 403 on the preflight, and the browser blocks the actual POST.

Why It Works Locally

Your local dev server runs on http://localhost:3000. During development:

  • There’s no CSP header (dev servers rarely set one)
  • You may have S3 CORS set to ["*"] for testing

Both gatekeepers are effectively disabled. Production tightens both — and the upload breaks.

The Debugging Flowchart

When a presigned POST fails, walk through this:

Request fails
  │
  ├─ "Provisional headers are shown" in Network tab?
  │   └─ YES → CSP is blocking it (check Console for CSP error)
  │
  ├─ OPTIONS returns 403?
  │   └─ YES → S3 CORS misconfigured (check allowed_origins)
  │
  ├─ POST returns 403 with AccessDenied XML?
  │   └─ YES → Presigned URL issue (expired, wrong conditions, bucket policy)
  │
  └─ POST returns 204 but frontend shows error?
      └─ Check if response has CORS headers (Access-Control-Allow-Origin)

In our case, it’s the first path: CSP kills it before it starts.

Three Solutions — From Quick Fix to Production Architecture

Solution 1: Add S3 Domain to CSP (The Quick Fix)

Simply add the S3 domain to your frontend CloudFront’s connect-src:

# In your frontend's CloudFront response headers policy
content_security_policy = join("; ", [
  "default-src 'self'",
  join(" ", [
    "connect-src", "'self'",
    "https://api.app.example.com",
    "https://*.s3.us-east-1.amazonaws.com"   # ← added
  ]),
  "upgrade-insecure-requests;"
])

Pros:

  • Single line change
  • No backend changes

Cons:

  • Exposes to the world that you use S3 (visible in response headers)
  • Wildcard allows connections to any S3 bucket in the region
  • Security teams will flag this in audits

Solution 2: Expose Only Your Bucket via SSM (The Scoped Fix)

Export the exact bucket domain from your infrastructure:

# In the repo that owns the S3 bucket
resource "aws_ssm_parameter" "bucket_domain" {
  name           = "/${var.environment_name}/bucket/domain"
  type           = "String"
  insecure_value = aws_s3_bucket.bucket_regional_domain_name
}

Pros:

  • Scoped to your exact bucket
  • Decoupled via SSM (bucket rename auto-propagates)

Cons:

  • Still exposes S3 in the CSP header
  • Cross-repo dependency and deploy ordering required

Solution 3: CloudFront Proxy (The Architect’s Choice)

Don’t let the browser talk to S3 at all. Instead, add a CloudFront behavior that proxies the upload through your API domain.

The Architecture Shift

Before:

Browser ──POST──→ S3 bucket (cross-origin, CSP blocks it)

After:

Browser ──POST──→ api.app.example.com/{id}/purchase-order-upload
                      │
                      └──→ CloudFront behavior → S3 bucket origin

The browser only ever talks to api.app.example.com, which is already in the CSP. CloudFront forwards the presigned POST to S3 transparently.

Implementation

Step 1 — New S3 origin in the API CloudFront distribution:

origin {
  origin_id   = "s3_upload_origin"
  domain_name = nonsensitive(data.aws_ssm_parameter.bucket_domain.value)

  s3_origin_config {
    origin_access_identity = ""
  }
}

Step 2 — CloudFront Function to rewrite the path:

S3 presigned POST expects the request at /. CloudFront needs to strip the path:

resource "aws_cloudfront_function" "s3_upload_rewriter" {
  name    = "api-s3-upload-rewriter${local.environment_id_suffix}"
  runtime = "cloudfront-js-2.0"
  comment = "Rewrites upload path to / for S3 presigned POST"
  code    = <<-EOF
            function handler(event) {
              var request = event.request;
              if (request.method === 'OPTIONS') {
                return {
                  statusCode: 204,
                  statusDescription: 'NoContent'
                };
              }
              request.uri = '/';
              return request;
            }
            EOF
}

Step 3 — Cache behavior for the upload path:

ordered_cache_behavior {
  path_pattern     = "/v1/*/purchase-order-upload"
  target_origin_id = "s3_upload_origin"
  allowed_methods  = ["HEAD", "POST", "GET", "OPTIONS", "PUT", "PATCH", "DELETE"]
  cached_methods   = ["GET", "HEAD"]

  viewer_protocol_policy     = "https-only"
  cache_policy_id            = data.aws_cloudfront_cache_policy.CachingDisabled.id
  origin_request_policy_id   = data.aws_cloudfront_origin_request_policy.S3Origin.id
  response_headers_policy_id = aws_cloudfront_response_headers_policy.security_headers_policy.id

  function_association {
    event_type   = "viewer-request"
    function_arn = aws_cloudfront_function.s3_upload_rewriter.arn
  }
}

Important: This behavior must appear before the broader /v1/*/purchaseOrders* behavior. CloudFront evaluates ordered_cache_behavior blocks in declaration order and uses the first match.

Step 4 — Lambda returns the proxy URL instead of the S3 URL:

// Before
return {
  uploadUrl: url,  // https://bucket.s3.amazonaws.com
  fields: fields,
};

// After
const proxyUploadUrl = `${cfg.apiEndpoint}/v1/${Id}/purchase-order-upload`;
return {
  uploadUrl: proxyUploadUrl,  // https://api.app.example.com/v1/123/purchase-order-upload
  fields: fields,
};

The presigned form fields (X-Amz-Credential, X-Amz-Signature, Policy, etc.) are sent as form data by the browser and forwarded by CloudFront to S3. S3 validates the signature as normal.

Pros:

  • Zero infrastructure exposure — S3 domain never reaches the browser
  • No CSP changes needed
  • No S3 CORS changes needed (CloudFront handles the origin mapping)
  • Cleaner security audit posture

Cons:

  • More moving parts (new origin, function, behavior)
  • Slight latency through CloudFront (negligible for uploads)
  • Three repos to coordinate for deployment

The Lesson

CORS and CSP errors in browser-to-S3 uploads are not bugs in your code — they’re the security model working as designed. The browser is doing its job: blocking requests to domains your application didn’t explicitly authorize.

The real question isn’t “how do I fix CORS?” — it’s “should my user’s browser know about my S3 bucket at all?”

If the answer is no, put CloudFront in front. Your API domain becomes the single point of contact. Your infrastructure stays invisible. And the next security audit becomes a lot shorter.

If you’ve dealt with similar CORS/CSP battles in production, I’d love to hear your approach. Drop a comment or connect with me.


메타데이터
post_id
a3accd544d36
slug
why-your-presigned-s3-upload-fails-in-production-and-the-cloudfront-fix-nobody-talks-about-a3accd544d36
url
https://medium.com/@sonal.sadafal/why-your-presigned-s3-upload-fails-in-production-and-the-cloudfront-fix-nobody-talks-about-a3accd544d36
canonical_url
https://medium.com/@sonal.sadafal/why-your-presigned-s3-upload-fails-in-production-and-the-cloudfront-fix-nobody-talks-about-a3accd544d36
author_url
https://medium.com/@sonal.sadafal
status
ok
fetched_at
2026-06-09 15:37:30