DEV Community

Cover image for Building a Secure Serverless Upload Pattern on AWS with Terraform ๐Ÿš€
francotel
francotel

Posted on

Building a Secure Serverless Upload Pattern on AWS with Terraform ๐Ÿš€

๐——๐—ถ๐—ฟ๐—ฒ๐—ฐ๐˜ ๐˜‚๐—ฝ๐—น๐—ผ๐—ฎ๐—ฑ๐˜€. ๐—ญ๐—ฒ๐—ฟ๐—ผ ๐—ฏ๐—ฎ๐—ฐ๐—ธ๐—ฒ๐—ป๐—ฑ ๐—ฏ๐—ผ๐˜๐˜๐—น๐—ฒ๐—ป๐—ฒ๐—ฐ๐—ธ๐˜€. ๐—˜๐—ป๐˜๐—ฒ๐—ฟ๐—ฝ๐—ฟ๐—ถ๐˜€๐—ฒ-๐—ด๐—ฟ๐—ฎ๐—ฑ๐—ฒ ๐˜€๐—ฒ๐—ฐ๐˜‚๐—ฟ๐—ถ๐˜๐˜†.

๐Ÿง  ๐—” ๐—ฅ๐—ฒ๐—ฎ๐—น-๐—ช๐—ผ๐—ฟ๐—น๐—ฑ ๐—ฃ๐—ฟ๐—ผ๐—ฏ๐—น๐—ฒ๐—บ (๐—ง๐—ต๐—ฎ๐˜ ๐—œ ๐—ž๐—ฒ๐—ฒ๐—ฝ ๐—ฆ๐—ฒ๐—ฒ๐—ถ๐—ป๐—ด)

A few weeks ago, I was reviewing a system where users were uploading files (some >300MB).

The original flow looked โ€œreasonableโ€:

  1. Frontend uploads the file to the backend
  2. Backend processes the request
  3. Backend uploads the file to S3
  4. Backend responds

But in practice, the system was ๐—ณ๐—ฎ๐—น๐—น๐—ถ๐—ป๐—ด ๐—ฎ๐—ฝ๐—ฎ๐—ฟ๐˜:

โŒ Timeouts
โŒ Lambda memory spikes
โŒ High AWS bills
โŒ Angry users

And the root cause was always the same:

๐—ง๐—ต๐—ฒ ๐—ฏ๐—ฎ๐—ฐ๐—ธ๐—ฒ๐—ป๐—ฑ ๐˜€๐—ต๐—ผ๐˜‚๐—น๐—ฑ ๐—ก๐—˜๐—ฉ๐—˜๐—ฅ ๐—ต๐—ฎ๐—ป๐—ฑ๐—น๐—ฒ ๐—ณ๐—ถ๐—น๐—ฒ ๐˜‚๐—ฝ๐—น๐—ผ๐—ฎ๐—ฑ๐˜€ ๐—ถ๐—ป ๐—ฎ ๐˜€๐—ฒ๐—ฟ๐˜ƒ๐—ฒ๐—ฟ๐—น๐—ฒ๐˜€๐˜€ ๐—ฎ๐—ฟ๐—ฐ๐—ต๐—ถ๐˜๐—ฒ๐—ฐ๐˜๐˜‚๐—ฟ๐—ฒ.


๐Ÿ’ก ๐—ง๐—ต๐—ฒ ๐—ฃ๐—ฎ๐˜๐˜๐—ฒ๐—ฟ๐—ป ๐—ง๐—ต๐—ฎ๐˜ ๐—™๐—ถ๐˜…๐—ฒ๐˜€ ๐—˜๐˜ƒ๐—ฒ๐—ฟ๐˜†๐˜๐—ต๐—ถ๐—ป๐—ด
The solution is a ๐˜„๐—ฒ๐—น๐—น-๐—ธ๐—ป๐—ผ๐˜„๐—ป ๐—ฏ๐˜‚๐˜ ๐—ผ๐—ณ๐˜๐—ฒ๐—ป ๐—บ๐—ถ๐˜€๐˜‚๐˜€๐—ฒ๐—ฑ ๐—ฝ๐—ฎ๐˜๐˜๐—ฒ๐—ฟ๐—ป:

๐Ÿ‘‰ ๐—ฆ๐Ÿฏ ๐—ฃ๐—ฟ๐—ฒ๐˜€๐—ถ๐—ด๐—ป๐—ฒ๐—ฑ ๐—จ๐—ฅ๐—Ÿ๐˜€

Instead of uploading files ๐˜ต๐˜ฉ๐˜ณ๐˜ฐ๐˜ถ๐˜จ๐˜ฉ your backend, you let the client upload ๐—ฑ๐—ถ๐—ฟ๐—ฒ๐—ฐ๐˜๐—น๐˜† ๐˜๐—ผ ๐—ฆ๐Ÿฏ, but in a ๐—ฐ๐—ผ๐—ป๐˜๐—ฟ๐—ผ๐—น๐—น๐—ฒ๐—ฑ, ๐˜๐—ฒ๐—บ๐—ฝ๐—ผ๐—ฟ๐—ฎ๐—ฟ๐˜†, ๐—ฎ๐—ป๐—ฑ ๐˜€๐—ฒ๐—ฐ๐˜‚๐—ฟ๐—ฒ ๐˜„๐—ฎ๐˜†.

This is the same pattern used by:
โ— Fintech platforms
โ— Healthcare systems
โ— Large SaaS products


๐Ÿงฉ ๐—›๐—ผ๐˜„ ๐˜๐—ต๐—ฒ ๐—”๐—ฟ๐—ฐ๐—ต๐—ถ๐˜๐—ฒ๐—ฐ๐˜๐˜‚๐—ฟ๐—ฒ ๐—ช๐—ผ๐—ฟ๐—ธ๐˜€
๐—›๐—ถ๐—ด๐—ต-๐—น๐—ฒ๐˜ƒ๐—ฒ๐—น ๐—ณ๐—น๐—ผ๐˜„:

1๏ธโƒฃ Client asks permission to upload a file
2๏ธโƒฃ API Gateway โ†’ Lambda generates a ๐—ฃ๐—ฟ๐—ฒ๐˜€๐—ถ๐—ด๐—ป๐—ฒ๐—ฑ ๐—จ๐—ฅ๐—Ÿ
3๏ธโƒฃ Client uploads ๐—ฑ๐—ถ๐—ฟ๐—ฒ๐—ฐ๐˜๐—น๐˜† ๐˜๐—ผ ๐—ฆ๐Ÿฏ using PUT
4๏ธโƒฃ Backend never touches the file

๐ŸŽฏ Result:
โ— Zero bottlenecks
โ— Minimal Lambda execution time
โ— Lower cost
โ— Better UX


๐Ÿ” ๐—ช๐—ต๐—ฎ๐˜ ๐—œ๐˜€ ๐—ฎ ๐—ฃ๐—ฟ๐—ฒ๐˜€๐—ถ๐—ด๐—ป๐—ฒ๐—ฑ ๐—จ๐—ฅ๐—Ÿ (๐—œ๐—ป ๐—ฆ๐—ถ๐—บ๐—ฝ๐—น๐—ฒ ๐—ง๐—ฒ๐—ฟ๐—บ๐˜€)?

Think of a presigned URL as:
๐ŸŽŸ๏ธ ๐—” ๐˜๐—ฒ๐—บ๐—ฝ๐—ผ๐—ฟ๐—ฎ๐—ฟ๐˜†, ๐˜€๐—ถ๐—ป๐—ด๐—น๐—ฒ-๐—ฝ๐˜‚๐—ฟ๐—ฝ๐—ผ๐˜€๐—ฒ ๐˜๐—ถ๐—ฐ๐—ธ๐—ฒ๐˜
โ— Valid only for a few minutes
โ— Allows only one specific action (e.g. PUT)
โ— Scoped to a single object
โ— No AWS credentials exposed

Once it expires โ†’ ๐—ถ๐˜โ€™๐˜€ ๐˜‚๐˜€๐—ฒ๐—น๐—ฒ๐˜€๐˜€.


โš™๏ธ ๐—Ÿ๐—ฎ๐—บ๐—ฏ๐—ฑ๐—ฎ: ๐—š๐—ฒ๐—ป๐—ฒ๐—ฟ๐—ฎ๐˜๐—ถ๐—ป๐—ด ๐˜๐—ต๐—ฒ ๐—ฃ๐—ฟ๐—ฒ๐˜€๐—ถ๐—ด๐—ป๐—ฒ๐—ฑ ๐—จ๐—ฅ๐—Ÿ (๐—ก๐—ผ๐—ฑ๐—ฒ.๐—ท๐˜€)

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "us-east-1" });

export const handler = async (event) => {
  const { fileName, contentType } = JSON.parse(event.body);

  const key = uploads/${Date.now()}-${fileName};

  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });

  const uploadUrl = await getSignedUrl(s3, command, {
    expiresIn: 300, // 5 minutes
  });

  return {
    statusCode: 200,
    body: JSON.stringify({ uploadUrl, key }),
  };
};
Enter fullscreen mode Exit fullscreen mode

flow-request

[!WARNING]
โš ๏ธ ๐—œ๐—บ๐—ฝ๐—ผ๐—ฟ๐˜๐—ฎ๐—ป๐˜:
The Lambda never sees the file, it only authorizes the upload.


๐Ÿ›ก๏ธ ๐—ฆ๐—ฒ๐—ฐ๐˜‚๐—ฟ๐—ถ๐˜๐˜† ๐—Ÿ๐—ฎ๐˜†๐—ฒ๐—ฟ๐˜€ (๐—ช๐—ต๐—ฎ๐˜ ๐— ๐—ฎ๐—ธ๐—ฒ๐˜€ ๐—ง๐—ต๐—ถ๐˜€ ๐—ฃ๐—ฟ๐—ผ๐—ฑ๐˜‚๐—ฐ๐˜๐—ถ๐—ผ๐—ป-๐—ฅ๐—ฒ๐—ฎ๐—ฑ๐˜†)

This PoC follows ๐—ฟ๐—ฒ๐—ฎ๐—น ๐—ฒ๐—ป๐˜๐—ฒ๐—ฟ๐—ฝ๐—ฟ๐—ถ๐˜€๐—ฒ ๐—ฝ๐—ฟ๐—ฎ๐—ฐ๐˜๐—ถ๐—ฐ๐—ฒ๐˜€, not just demos.

๐Ÿ” ๐Ÿญ. ๐—ฆ๐—ต๐—ผ๐—ฟ๐˜-๐—น๐—ถ๐˜ƒ๐—ฒ๐—ฑ ๐—จ๐—ฅ๐—Ÿ๐˜€
โ— 5โ€“10 minutes max
โ— Enough for upload, useless afterward

๐Ÿ” ๐Ÿฎ. ๐—œ๐—”๐—  ๐—Ÿ๐—ฒ๐—ฎ๐˜€๐˜ ๐—ฃ๐—ฟ๐—ถ๐˜ƒ๐—ถ๐—น๐—ฒ๐—ด๐—ฒ

Lambda role can ๐—ผ๐—ป๐—น๐˜† do:

{
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::my-bucket/uploads/*"
}
Enter fullscreen mode Exit fullscreen mode

Nothing else.

๐Ÿ” ๐Ÿฏ. ๐—ฃ๐—ฟ๐—ถ๐˜ƒ๐—ฎ๐˜๐—ฒ ๐—ฆ๐Ÿฏ ๐—•๐˜‚๐—ฐ๐—ธ๐—ฒ๐˜
โ— No public access
โ— No ACL tricks
โ— Only presigned URLs work

๐Ÿ” ๐Ÿฐ. ๐—ฆ๐—ฎ๐—ป๐—ถ๐˜๐—ถ๐˜‡๐—ฒ๐—ฑ ๐—ข๐—ฏ๐—ท๐—ฒ๐—ฐ๐˜ ๐—ก๐—ฎ๐—บ๐—ฒ๐˜€
โ— UUID-based keys
โ— No user-controlled paths
โ— No path traversal risks

๐Ÿ” ๐Ÿฑ. ๐—–๐—ข๐—ฅ๐—ฆ ๐—–๐—ผ๐—ฟ๐—ฟ๐—ฒ๐—ฐ๐˜๐—น๐˜† ๐—–๐—ผ๐—ป๐—ณ๐—ถ๐—ด๐˜‚๐—ฟ๐—ฒ๐—ฑ

Because ๐—–๐—ข๐—ฅ๐—ฆ ๐—ถ๐˜€ ๐—ฎ๐—น๐˜„๐—ฎ๐˜†๐˜€ ๐˜๐—ต๐—ฒ ๐—ณ๐—ถ๐—ฟ๐˜€๐˜ ๐˜๐—ต๐—ถ๐—ป๐—ด ๐˜๐—ต๐—ฎ๐˜ ๐—ฏ๐—ฟ๐—ฒ๐—ฎ๐—ธ๐˜€ ๐Ÿ˜…


๐Ÿงฑ ๐—œ๐—ป๐—ณ๐—ฟ๐—ฎ๐˜€๐˜๐—ฟ๐˜‚๐—ฐ๐˜๐˜‚๐—ฟ๐—ฒ ๐—ฎ๐˜€ ๐—–๐—ผ๐—ฑ๐—ฒ ๐˜„๐—ถ๐˜๐—ต ๐—ง๐—ฒ๐—ฟ๐—ฟ๐—ฎ๐—ณ๐—ผ๐—ฟ๐—บ

Everything is deployed using ๐—ง๐—ฒ๐—ฟ๐—ฟ๐—ฎ๐—ณ๐—ผ๐—ฟ๐—บ, so the architecture is:
โ— Reproducible
โ— Auditable
โ— Ready for CI/CD

Core components:
โ— S3 bucket
โ— Lambda
โ— IAM roles
โ— API Gateway (HTTP API)

๐Ÿ“Œ ๐˜๐˜ฏ ๐˜ต๐˜ฉ๐˜ฆ ๐˜ฑ๐˜ฐ๐˜ด๐˜ต ๐˜ ๐˜ง๐˜ฐ๐˜ค๐˜ถ๐˜ด ๐˜ฐ๐˜ฏ ๐˜ต๐˜ฉ๐˜ฆ ๐—ฎ๐—ฟ๐—ฐ๐—ต๐—ถ๐˜๐—ฒ๐—ฐ๐˜๐˜‚๐—ฟ๐—ฒ ๐—ฎ๐—ป๐—ฑ ๐—ฝ๐—ฎ๐˜๐˜๐—ฒ๐—ฟ๐—ป.
๐˜›๐˜ฉ๐˜ฆ ๐—ณ๐˜‚๐—น๐—น ๐—ง๐—ฒ๐—ฟ๐—ฟ๐—ฎ๐—ณ๐—ผ๐—ฟ๐—บ ๐—ถ๐—บ๐—ฝ๐—น๐—ฒ๐—บ๐—ฒ๐—ป๐˜๐—ฎ๐˜๐—ถ๐—ผ๐—ป ๐˜ญ๐˜ช๐˜ท๐˜ฆ๐˜ด ๐˜ช๐˜ฏ ๐˜ต๐˜ฉ๐˜ฆ ๐˜ณ๐˜ฆ๐˜ฑ๐˜ฐ๐˜ด๐˜ช๐˜ต๐˜ฐ๐˜ณ๐˜บ.


๐Ÿ“‚ ๐—ฅ๐—ฒ๐—ฝ๐—ผ๐˜€๐—ถ๐˜๐—ผ๐—ฟ๐˜† ๐—ฆ๐˜๐—ฟ๐˜‚๐—ฐ๐˜๐˜‚๐—ฟ๐—ฒ (๐—ฆ๐—ถ๐—บ๐—ฝ๐—น๐—ฒ ๐—ฏ๐˜† ๐——๐—ฒ๐˜€๐—ถ๐—ด๐—ป)

aws-s3-presigned-url-lambda-terraform/
โ”œโ”€โ”€ 01-s3.tf
โ”œโ”€โ”€ 02-lambda.tf
โ”œโ”€โ”€ 04-api.tf
โ”œโ”€โ”€ client/
โ”‚   โ”œโ”€โ”€ index.html
โ”‚   โ”œโ”€โ”€ index.html.tpl
โ”‚   โ”œโ”€โ”€ logo-client.png
โ”‚   โ””โ”€โ”€ logo-s3.png
โ”œโ”€โ”€ dev.tfvars
โ”œโ”€โ”€ drawio/
โ”‚   โ”œโ”€โ”€ aws-s3-presignend.gif
โ”‚   โ”œโ”€โ”€ aws-s3-url.drawio
โ”‚   โ”œโ”€โ”€ image-2.png
โ”‚   โ””โ”€โ”€ image.png
โ”œโ”€โ”€ lambda-function.zip
โ”œโ”€โ”€ main.tf
โ”œโ”€โ”€ Makefile
โ”œโ”€โ”€ provider.tf
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ security/
โ”‚   โ”œโ”€โ”€ checkov.yaml
โ”‚   โ””โ”€โ”€ trivy.yaml
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ index.mjs
โ”‚   โ”œโ”€โ”€ node_modules/
โ”‚   โ”œโ”€โ”€ package-lock.json
โ”‚   โ””โ”€โ”€ package.json
โ”œโ”€โ”€ terraform.tfstate
โ”œโ”€โ”€ terraform.tfstate.backup
โ”œโ”€โ”€ tfplan
โ”œโ”€โ”€ tfplan.json
โ”œโ”€โ”€ variables.tf
โ””โ”€โ”€ versions.tf
Enter fullscreen mode Exit fullscreen mode

๐ŸŽฏ Minimal, readable, and focused on the pattern.


๐Ÿงช ๐—ฅ๐—ฒ๐—พ๐˜‚๐—ถ๐—ฟ๐—ฒ๐—บ๐—ฒ๐—ป๐˜๐˜€ ๐˜๐—ผ ๐—ฅ๐˜‚๐—ป ๐˜๐—ต๐—ฒ ๐—ฃ๐—ผ๐—–

This PoC assumes:

โœ… macOS
โœ… Terraform already installed
โœ… AWS CLI already authenticated

No extra setup. No magic.


๐Ÿš€ ๐—ช๐—ฎ๐—ป๐˜ ๐˜๐—ต๐—ฒ ๐—™๐˜‚๐—น๐—น ๐—ช๐—ผ๐—ฟ๐—ธ๐—ถ๐—ป๐—ด ๐—ฃ๐—ผ๐—–?

I published the complete implementation here:

๐Ÿ‘‰ ๐—ต๐˜๐˜๐—ฝ๐˜€://๐—ด๐—ถ๐˜๐—ต๐˜‚๐—ฏ.๐—ฐ๐—ผ๐—บ/๐—ณ๐—ฟ๐—ฎ๐—ป๐—ฐ๐—ผ๐˜๐—ฒ๐—น/๐—ฎ๐˜„๐˜€-๐˜€๐Ÿฏ-๐—ฝ๐—ฟ๐—ฒ๐˜€๐—ถ๐—ด๐—ป๐—ฒ๐—ฑ-๐˜‚๐—ฟ๐—น-๐—น๐—ฎ๐—บ๐—ฏ๐—ฑ๐—ฎ-๐˜๐—ฒ๐—ฟ๐—ฟ๐—ฎ๐—ณ๐—ผ๐—ฟ๐—บ (๐—ต๐˜๐˜๐—ฝ๐˜€://๐—ด๐—ถ๐˜๐—ต๐˜‚๐—ฏ.๐—ฐ๐—ผ๐—บ/๐—ณ๐—ฟ๐—ฎ๐—ป๐—ฐ๐—ผ๐˜๐—ฒ๐—น/๐—ฎ๐˜„๐˜€-๐˜€๐Ÿฏ-๐—ฝ๐—ฟ๐—ฒ๐˜€๐—ถ๐—ด๐—ป๐—ฒ๐—ฑ-๐˜‚๐—ฟ๐—น-๐—น๐—ฎ๐—บ๐—ฏ๐—ฑ๐—ฎ-๐˜๐—ฒ๐—ฟ๐—ฟ๐—ฎ๐—ณ๐—ผ๐—ฟ๐—บ)

Youโ€™ll find:
โ— Full Terraform code
โ— Lambda (Node.js 20)
โ— Test scripts
โ— Architecture diagram

Clone it, deploy it, break it, improve it.


๐ŸŽฏ ๐—™๐—ถ๐—ป๐—ฎ๐—น ๐—ง๐—ต๐—ผ๐˜‚๐—ด๐—ต๐˜

If your backend is still handling file uploads, youโ€™re paying more ๐—ฎ๐—ป๐—ฑ scaling less.

This pattern:
โ— Reduces cost
โ— Improves reliability
โ— Scales naturally
โ— Matches real-world cloud architectures

demo

Inspiration and References

This project was inspired by the AWS blog post:

The article explores several best practices for securing presigned
URLs in serverless architectures, including checksum validation,
expiration strategies, and least-privilege IAM policies.
Enter fullscreen mode Exit fullscreen mode

๐Ÿค Let's Connect!

If you find this repository useful and want to see more content like this, follow me on LinkedIn to stay updated on more projects and resources!

LinkedIn

If youโ€™d like to support my work, you can buy me a coffee. Thank you for your support!

BuyMeACoffee

Thank you for reading! ๐Ÿ˜Š

Top comments (0)