Dark navy thumbnail with orange accents reading Presigned URLs Lie, 400MB in a 100KB bucket

What an S3 presigned URL actually signs

A 400 megabyte shell script landed in a bucket meant for 100 kilobyte avatars. No error. No alert. No bug in my code either. The upload endpoint did exactly what I wrote it to do. That is the uncomfortable part of an S3 presigned URL. It is not a permission grant with sensible defaults. It is one request you already authorized, handed to somebody you do not control. Whatever the signature leaves unspecified, the holder picks for you.

🚀 Complete JavaScript Guide (Beginner + Advanced)

🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)

Why the file should not pass through your server

The obvious upload route sends multipart form data to Express, lets Multer parse it, and pushes the buffer to S3. You get to validate the file on the way through, which feels safe.

I uploaded a 93 kilobyte file through that route. The heap delta on the request was 1.24 megabytes. The bytes enter your process, sit in memory, then go out again. Your server pays for that file twice.

// The version that costs you memory: 93 KB file, 1.24 MB heap delta
router.post("/upload", upload.single("file"), async (req, res) => {
  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: req.file.originalname,
    Body: req.file.buffer,
  }));
  res.json({ ok: true });
});

One avatar costs nothing. A container with a memory limit and ten concurrent uploads is a different story. So take the file out of your server’s path and shrink the server’s job down to handing out permission.

One detour once the browser talks to S3 directly: the bucket needs a CORS rule. Claude Code handed me a JSON file and the matching CLI command, and the console rejected the file with “Expected CORSConfiguration rules to be an array.” Both artifacts were correct. put-bucket-cors wants the wrapper object, the console wants the bare array, and neither tool tells you which shape it expects.

What an S3 presigned URL actually pins down

A presigned PUT commits to three things. The bucket, the key, and the expiry. Not the size. Not the content type.

// POST /presigned-put
const url = await getSignedUrl(
  s3,
  new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType }),
  { expiresIn: 300 }
);

That ContentType goes in as a suggestion. The client overrides it and S3 does not care.

So I wrote a script to prove it. It asks the route for a URL for a 200 kilobyte PNG, then pushes 400 megabytes of generated bytes at that URL with a content type of application/x-sh. No client-side check. The upload succeeded. HeadObject came back reporting 400 MB and a shell script content type, sitting under a key I signed for a PNG.

Picture that in production. Users can upload anything, at any size, and nothing errors. You find out from the bill, or from whatever downstream job tries to open that PNG.

Sign your constraints with presigned POST

createPresignedPost is the other signing API, and it closes what a plain S3 presigned URL leaves open. A policy travels with the request as a set of conditions, and S3 evaluates them before it accepts a byte.

const { url, fields } = await createPresignedPost(s3, {
  Bucket: BUCKET,
  Key: key,
  Conditions: [
    ["content-length-range", 1, 5 * 1024 * 1024], // 1 byte to 5 MB
    ["starts-with", "$Content-Type", "image/"],
  ],
  Fields: { "Content-Type": contentType },
  Expires: 300,
});

content-length-range is a hard floor and ceiling in bytes. starts-with on Content-Type lets the client pick image/png or image/jpeg and nothing else.

The browser side is fiddlier. You are not PUTting to a URL anymore. You are POSTing multipart form data, and the fields carry the policy and the signature.

const form = new FormData();
// Fields first. S3 evaluates the policy against the fields it has already
// parsed by the time it reaches the file. Anything appended after the file
// is invisible to the policy check, so the file goes LAST, every time.
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append("file", file);

await fetch(url, { method: "POST", body: form });

That ordering is a trap. Put the file first and your policy never gets read. With the policy in place, S3 rejects the audio file on a condition failure. It rejects the 400 megabyte payload too. Same validation you would normally write in middleware, except the server is not in the path.

Stop letting the client pick the key

One thing still bothered me. The keys came straight off the user’s filename. Collisions are the obvious problem, since two users upload avatar.png and one overwrites the other. The deeper issue is the same as before. Size is pinned, type is pinned, destination is still the client’s call.

// src/keys.ts
const EXT_BY_TYPE: Record<string, string> = {
  "image/png": "png",
  "image/jpeg": "jpg",
};

export function buildKey(accountId: string, contentType: string) {
  const ext = EXT_BY_TYPE[contentType];
  if (!ext) throw new Error("unsupported content type");
  return `uploads/${accountId}/${randomUUID()}.${ext}`;
}

The account ID comes from your auth layer. The extension comes from a server-side allow list, never from the client’s filename. Then I removed the key and fileName parameters from the route body entirely. Validating a client-supplied key would also work, but it leaves the parameter sitting there for someone to loosen later. Remove it and the unsafe version stops being expressible.

I finished by having Claude Code write one test per claim, then broke the oversize test on purpose to watch it go red. That took thirty seconds. If you cannot make a test fail, you do not know what it checks.

Key takeaways

  • A presigned PUT pins the bucket, the key, and the expiry and nothing else, so the holder gets to choose the size and the content type of whatever lands in your bucket.
  • Passing ContentType into getSignedUrl looks like a constraint, but S3 treats it as a suggestion the client can override silently.
  • createPresignedPost carries a policy with content-length-range and starts-with conditions that S3 enforces before it accepts a single byte.
  • In the browser, append every policy field to FormData before you append the file, because S3 stops reading fields once it reaches the file.
  • Build object keys on the server from the caller’s account ID and a UUID, and delete the key parameter from the route so nobody can pass one.

Conclusion

An S3 presigned URL is a request you already signed and handed to someone you cannot supervise. Because the bytes never reach your server, there is no second place to catch a bad upload. Go find your upload endpoint. Ask it for a URL for a small image, then push something large and wrong at it. Do not reason about whether it holds. Run it.

Share this article

Similar Posts