Unable to upload PDF to S3 bucket via presigned URL created from my Node backend

Scenario:

  1. Client (Nextjs client component) queries backend (NextJS rest endpoint) for pre-signed URL
  2. Backend gets presigned URL for S3 via getSignedUrl
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "@/env";

const AWS_REGION = "ap-southeast-2";

const s3Client = new S3Client({
  region: AWS_REGION,
  credentials: {
    accessKeyId: env.AWS_S3_ACCESS_ID,
    secretAccessKey: env.AWS_S3_ACCESS_KEY,
  },
});

const command = new PutObjectCommand({
     Bucket: s3Object.bucket,
     Key: s3Object.key,
     // I also tried passing the contenttype here to see if it changed anything. But no it didn't.
     // ContentType: s3Object.contentType,
     // Might need more fields here to refine...
});

// Calculate dynamic expiration time based on file size, user's internet connection speed, etc.
const expiresIn = calculateExpirationTime(s3Object);

const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn });

return presignedUrl;
  1. Client receives presigned URL from backend
  2. Client sends file chunk to presigned URL
// Uploads a chunk to the presigned URL
const uploadPart = (opts: {
  url: string;
  chunk: Blob;
  contentType: string;
  chunkSize: number;
  fileName: string;
  maxRetries: number;
}) =>
  fetch(opts.url, {
    method: "PUT",
    body: opts.chunk,
    //headers: {
    //  "Content-Type": opts.contentType,
    //},
  })
  1. Frontend throws a CORs error:
Access to fetch at 'https://mybucket.s3.ap-southeast-2.amazonaws.com/mydoc.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AMZ_VALUE%2Fap-southeast-2%2Fs3%2Faws4_request&X-Amz-Date=20240516T010037Z&X-Amz-Expires=14400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host&x-id=PutObject' from origin 'http://localhost:3000' 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. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

OPTIONS request is sent and comes back as 403 Forbidden

Request Method:
OPTIONS
Status Code:
403 Forbidden
Remote Address:
<IP>:443
Referrer Policy:
strict-origin-when-cross-origin

RESPONSE HEADERS:

Content-Type:
application/xml
Date:
Thu, 16 May 2024 01:03:33 GMT
Server:
AmazonS3
Transfer-Encoding:
chunked
X-Amz-Id-2:
<ID>
X-Amz-Request-Id:
<ID>

REQUEST HEADERS:

Accept:
*/*
Accept-Encoding:
gzip, deflate, br, zstd
Accept-Language:
en-US,en;q=0.9
Access-Control-Request-Method:
PUT
Cache-Control:
no-cache
Connection:
keep-alive
Host:
mybucket.s3.ap-southeast-2.amazonaws.com
Origin:
http://localhost:3000
Pragma:
no-cache
Referer:
http://localhost:3000/
Sec-Fetch-Dest:
empty
Sec-Fetch-Mode:
cors
Sec-Fetch-Site:
cross-site
User-Agent:
<AGENT>

S3 Bucket Permissions

I have configured this correctly. I have tried many different variations including explicitly defining localhost:3000 in the AllowedOrigins.

CORS settings:

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT",
            "POST",
            "DELETE",
            "HEAD"
        ],
        "AllowedOrigins": [
            "*"
        ]
    }
]

Policy attached to the user

The user who is generating the presigned URL has the following policy attached:

  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": [
          "s3:GetObject",
          "s3:GetObjectVersion",
          "s3:PutObject"
        ],
        "Resource": [
          "arn:aws:s3:::mybucket",
          "arn:aws:s3:::bmybucket/*"
        ],
        "Effect": "Allow"
      }
    ]
  }

I have been struggling with this one for quite some time and tried so many of the suggestions from around SO and Google. None have worked. I have a feeling I have missed some small detail and it’s causing me all this grief.