Building a Secure Serverless Upload Pattern on AWS with Terraform 🚀
Source: Dev.to
A Real‑World Problem (That I Keep Seeing)
A few weeks ago I reviewed a system where users uploaded files (some > 300 MB).
The original flow looked “reasonable”:
- Frontend uploads the file to the backend
- Backend processes the request
- Backend uploads the file to S3
- Backend responds
In practice the system was failing apart:
- ❌ Timeouts
The root cause was always the same: the backend should never handle file uploads in a serverless architecture.
The Pattern That Fixes Everything
S3 Presigned URLs
Instead of routing files through the backend, let the client upload directly to S3 using a controlled, temporary, and secure presigned URL. This is the same pattern used by many production‑grade serverless applications.
What Is a Presigned URL (in Simple Terms)?
A presigned URL is a time‑limited, signed request that allows anyone who possesses it to perform a specific S3 operation (e.g., PutObject). Once the URL expires, it becomes useless.
Lambda: Generating the Presigned URL (Node.js)
// src/index.mjs
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 }),
};
};
Security Layers (What Makes This Production‑Ready)
- Short‑lived URLs – limited to a few minutes.
- IAM Least‑Privilege – Lambda role can only
s3:PutObjecttoarn:aws:s3:::my-bucket/uploads/*. - Private S3 Bucket – no public access.
- Sanitized Object Names – generated server‑side to avoid path traversal.
- Proper CORS Configuration – allows only the intended origins to upload.
⚠️ Important: CORS is often the first thing that breaks uploads; ensure it’s correctly configured on the bucket.
Infrastructure as Code with Terraform
All resources are defined in Terraform. The repository structure looks like this:
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
The core components are:
- S3 bucket with restricted access and CORS settings.
- Lambda function that generates presigned URLs.
- API Gateway (or HTTP API) exposing a
/presignendpoint to the client.
Requirements to Run the PoC
- macOS (or any Unix‑like environment with the AWS CLI, Node.js, and Terraform installed).
- No additional setup beyond cloning the repo and running
terraform apply.
Full Working PoC
The complete implementation is available at:
https://github.com/francotel/aws-s3-presigned-url-lambda-terraform
Clone the repository, deploy with Terraform, and you’ll have a fully functional serverless upload flow.
Final Thought
If your backend still handles file uploads, you’re paying more and scaling less efficiently. Using presigned URLs moves the heavy lifting to S3, reduces backend load, and follows best‑practice security patterns.
References
- AWS Blog: Securing Amazon S3 presigned URLs for serverless applications
The article discusses best practices such as checksum validation, expiration strategies, and least‑privilege IAM policies.