When you ask Claude Code or Cursor to "store uploads in S3", the agent has to create the bucket, lock it down and wire presigned URLs. Here is the AWS SDK route and the Encore route, with code you can hand to the agent.
"Let users upload receipts and store them in S3" is a typical prompt for a coding agent, and the code it writes for the happy path is usually fine.
The parts that cause trouble later are the ones around the code, such as whether the bucket is private and which key a user is allowed to write. A model will happily produce a bucket policy that works and is too open, because a working upload is what it was asked for.
Here is what each route asks the agent to produce for the same feature in a TypeScript backend: an endpoint that hands out an upload URL for an order's receipt, and one that hands out a download URL.
The standard route uses the AWS SDK for JavaScript v3 and its presigner. The application code is short:
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({});
const Bucket = process.env.RECEIPTS_BUCKET!;
export async function receiptUploadUrl(orderId: string): Promise<string> {
const command = new PutObjectCommand({
Bucket,
Key: `${orderId}.pdf`,
ContentType: "application/pdf",
});
return getSignedUrl(s3, command, { expiresIn: 300 });
}
export async function receiptDownloadUrl(orderId: string): Promise<string> {
return getSignedUrl(s3, new GetObjectCommand({ Bucket, Key: `${orderId}.pdf` }), {
expiresIn: 300,
});
}
A presigned URL carries the permissions of whoever signed it, so the service's IAM role needs s3:PutObject and s3:GetObject on this bucket and nothing broader. The agent also has to create the bucket with Block Public Access turned on, and add a CORS rule so browsers can PUT to it. None of that is visible to the TypeScript compiler, so the first test of the IAM policy is a real upload against a real bucket.
Encore combines an open source SDK for declaring the infrastructure a service uses with a platform that provisions it locally and in your own AWS account. The agent declares the bucket with the object storage primitive and writes the two endpoints next to it:
import { api } from "encore.dev/api";
import { Bucket } from "encore.dev/storage/objects";
// A private S3 bucket in AWS, and local object storage under encore run.
export const receipts = new Bucket("receipts", { versioned: false });
interface ReceiptRequest {
orderId: string;
}
interface SignedUrl {
url: string;
}
// The client uploads the PDF straight to the bucket with this URL.
export const uploadUrl = api(
{ expose: true, method: "POST", path: "/receipts/:orderId/upload-url" },
async ({ orderId }: ReceiptRequest): Promise<SignedUrl> => {
const { url } = await receipts.signedUploadUrl(`${orderId}.pdf`, { ttl: 300 });
return { url };
},
);
export const downloadUrl = api(
{ expose: true, method: "GET", path: "/receipts/:orderId/download-url" },
async ({ orderId }: ReceiptRequest): Promise<SignedUrl> => {
const { url } = await receipts.signedDownloadUrl(`${orderId}.pdf`, { ttl: 300 });
return { url };
},
);
Under encore run the bucket is backed by local object storage, so the agent can call POST /receipts/o1/upload-url, upload a file to the returned URL and fetch it back through the download URL in the same session. With the local MCP server it can inspect the bucket and read the trace for each request.
An agent asked for receipt uploads produces one TypeScript file, and the reviewer sees the bucket declaration in the same diff as the endpoints that use it.
On deploy, Encore creates a private S3 bucket with a unique name in your AWS account and grants the service permissions for that bucket only. A bucket declared with public: true is served through CloudFront instead. The agent declares what the service stores, and cloud access sits with Encore's IAM role, not the agent. An S3 bucket the company already has can be imported rather than recreated.
The upload rules from OWASP's file upload guidance still apply in both routes, starting with generating the object key on the server and limiting file size before trusting a file. Encore handles the bucket and its permissions, and lifecycle rules or replication beyond what the declaration covers are configured on the AWS side.
Run in your terminal to get started locally.
With Encore, ask the agent to declare a Bucket in the service and return signed upload and download URLs from two endpoints. encore run provides local object storage so the agent can upload a file and fetch it back, and on deploy Encore creates a private S3 bucket in your AWS account with permissions scoped to that service. Without Encore, the agent writes the bucket, its public access block and CORS rules, plus an IAM policy for the service, and uses the AWS SDK presigner in code.
For anything larger than a small form field, straight to S3 with a presigned URL is the usual choice. The API issues a short-lived URL for one object key, and the file never passes through the application servers.
On plain AWS, a bucket policy or ACL written by the agent can do that unless S3 Block Public Access is enabled. With Encore, buckets are private unless declared public in code, and cloud access sits with Encore's IAM role, not the agent.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.