Skip to main content
All posts
  • Node.js
  • AWS S3
  • Security
  • Express

Secure File Uploads in Node.js with AWS S3 Presigned URLs

Stop streaming uploads through your server. A practical guide to S3 presigned PUT and POST uploads with the AWS SDK v3 — validation, size limits, CORS and the security details that matter.

SK
Santosh Kumar

· 6 min read

On this page

The first file upload most of us build looks like this: the browser sends the file to our API, the API (with multer or similar) buffers it, then uploads it to S3. It works. It also means every byte goes through your server twice, your instances hold large files in memory, and on platforms like Vercel or API Gateway you hit request-body limits (4.5 MB and 10 MB respectively) before you hit anything else.

Presigned URLs fix this. Your server never touches the file. It just hands the browser a short-lived, signed permission to upload one specific object directly to S3. I use this pattern for every upload feature I build now, and this post covers how to do it securely.

How presigned uploads work#

  1. The browser asks your API: "I want to upload a 2 MB PNG."
  2. Your API checks the user is allowed to, decides the object key, and signs an upload URL valid for about a minute.
  3. The browser uploads the file straight to S3 with that URL.
  4. The browser tells your API "done", and your API records the key.

The signature is created with your AWS credentials on the server. The browser never sees those credentials, only a URL that works for one object, for a short time.

Setup#

Install the AWS SDK v3 packages:

Terminal
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Lock the bucket down first:

  • Keep Block Public Access on. Uploaded files are private by default; you serve them through presigned GET URLs or CloudFront, never a public bucket.
  • Give your server an IAM role or user that can only s3:PutObject / s3:GetObject on that bucket (or a prefix of it), nothing else.

Option 1: Presigned PUT (simplest)#

routes/uploads.ts
import { randomUUID } from "crypto";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: process.env.AWS_REGION });

const ALLOWED_TYPES: Record<string, string> = {
  "image/jpeg": "jpg",
  "image/png": "png",
  "image/webp": "webp",
  "application/pdf": "pdf",
};

router.post("/uploads/presign", requireAuth, async (req, res) => {
  const { contentType } = req.body;
  const ext = ALLOWED_TYPES[contentType];
  if (!ext) return res.status(400).json({ error: "File type not allowed" });

  // The server picks the key. Never let the client choose the path.
  const key = `uploads/${req.user.id}/${randomUUID()}.${ext}`;

  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      ContentType: contentType,
    }),
    {
      expiresIn: 60, // seconds
      // Make Content-Type part of the signature so the browser can't swap file types
      signableHeaders: new Set(["content-type"]),
    },
  );

  res.json({ url, key });
});

And in the browser:

TypeScript
const { url, key } = await api.post("/uploads/presign", { contentType: file.type });

await fetch(url, {
  method: "PUT",
  headers: { "Content-Type": file.type }, // must match what was signed
  body: file,
});

await api.post("/uploads/complete", { key });

Because content-type is listed in signableHeaders, it's part of the signature, and the upload fails with SignatureDoesNotMatch if the browser sends a different Content-Type. That stops someone from requesting a URL for a PNG and uploading an HTML file instead. (It's also the most common reason a presigned upload "mysteriously" fails: the header the browser sends must match exactly.)

Option 2: Presigned POST (when you need a size limit)#

A presigned PUT doesn't let you enforce a maximum file size. Someone could request a URL and upload 5 GB to it. If size matters (and for user uploads it usually does), use a presigned POST with a policy instead:

Terminal
npm install @aws-sdk/s3-presigned-post
TypeScript
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";

const MAX_BYTES = 10 * 1024 * 1024; // 10 MB

const { url, fields } = await createPresignedPost(s3, {
  Bucket: process.env.S3_BUCKET!,
  Key: key,
  Conditions: [
    ["content-length-range", 1, MAX_BYTES], // S3 rejects anything outside this
    ["eq", "$Content-Type", contentType],
  ],
  Fields: { "Content-Type": contentType },
  Expires: 60,
});

res.json({ url, fields, key });

The browser sends a multipart/form-data POST with every returned field, with the file last:

TypeScript
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append("file", file); // must be the last field

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

Now S3 itself enforces the size and type, regardless of what the client claims.

Configure CORS on the bucket#

Browsers block cross-origin uploads unless the bucket allows them. In the S3 console under Permissions → CORS:

JSON
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Use your real origins, not "*". Add http://localhost:3000 in a separate dev bucket rather than in production.

Verify the upload on the server#

The "complete" call is where you record the file, so don't trust it blindly. Check the object actually exists and matches what you expect:

TypeScript
import { HeadObjectCommand } from "@aws-sdk/client-s3";

router.post("/uploads/complete", requireAuth, async (req, res) => {
  const { key } = req.body;

  // The key must be inside this user's own prefix
  if (!key.startsWith(`uploads/${req.user.id}/`)) {
    return res.status(403).json({ error: "Forbidden" });
  }

  const head = await s3.send(new HeadObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key }));
  if ((head.ContentLength ?? 0) > MAX_BYTES) {
    return res.status(400).json({ error: "File too large" });
  }

  await Files.create({ userId: req.user.id, key, size: head.ContentLength });
  res.json({ ok: true });
});

That prefix check is easy to forget and important: without it, user A could "complete" with user B's key and attach someone else's file to their account.

Serving private files#

For private files, generate a presigned GET URL when the user needs it:

TypeScript
import { GetObjectCommand } from "@aws-sdk/client-s3";

const url = await getSignedUrl(
  s3,
  new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key }),
  { expiresIn: 300 },
);

For public assets that many people load (avatars, product images), put CloudFront in front of the bucket with Origin Access Control instead. You get caching and a CDN, and the bucket stays private.

Security checklist#

  • ✅ Server chooses the object key, scoped to the user (uploads/<userId>/<uuid>)
  • ✅ Allow-list content types; sign Content-Type into the URL
  • ✅ Use presigned POST with content-length-range when size matters
  • ✅ Short expiry (60 seconds is plenty for the browser to start the upload)
  • ✅ Bucket stays private; serve via presigned GET or CloudFront
  • ✅ Verify ownership and size with HeadObject before saving the record
  • ✅ CORS limited to your real origins

Wrapping up#

Presigned uploads move the heavy lifting to S3, remove body-size limits from your API, and keep credentials on the server. The pattern is the same whether your backend is Express, Fastify or Next.js route handlers: authorize, sign a narrowly scoped URL, let the browser upload directly, then verify before you trust it.