S3 presigned URL: secure file uploads with Lambda

2026-11-15来源 dev.to AWSEnglish
S3 presigned URL: secure file uploads with Lambda

An S3 presigned URL is, in my view, one of the most underrated features in AWS. As soon as an application accepts files, whether photos, PDFs or exports, the same question comes up: do I push the file through my Lambda function or straight into the bucket? Many teams start with the first option because it feels familiar. By the first 8 MB phone photo at the latest, you run into hard limits.

In this article you build an upload where the Lambda function only issues a short-lived permission and the browser sends the file to S3 itself. The example is a fictional bike rental: when returning a bike, customers photograph scratches or a flat tyre and upload the pictures. You get the complete code with the Serverless Framework, tests with Vitest and the pitfalls I trip over most often with this pattern.

In short

With an S3 presigned URL, the browser uploads files directly into a private bucket while your Lambda function only decides target, file type, size and expiry. For browser uploads, a presigned POST form is usually the better choice than PUT, because S3 then enforces the file size itself. The most common pitfall is the automatic CRC32 checksum in newer AWS SDK versions, which can break presigned PUT URLs.

What you will learn in this article

  • How an S3 presigned URL works and how long it really stays valid
  • When to use PUT and when to use a presigned POST form
  • A complete upload API with Lambda, TypeScript and the Serverless Framework
  • How to check uploaded files for their real type afterwards
  • The typical errors from SignatureDoesNotMatch to CORS and how to fix them

Why files should not pass through your Lambda function

The obvious approach looks like this: the browser sends the file to API Gateway, the Lambda function receives it and writes it to S3 with PutObject. That works for small files, but it has two hard limits. According to AWS, an HTTP API in API Gateway accepts at most 10 MB per request. A synchronous Lambda invocation is even limited to a 6 MB payload, and because binary data usually arrives Base64-encoded via API Gateway, even less of that is left in practice.

Then there is the cost. The function waits while the file trickles through the network byte by byte, and you pay for that waiting time in GB-seconds. On a slow mobile connection, a single upload can take several seconds. On top of that, every request keeps an execution environment busy for that time, which puts pressure on your concurrency limit when many uploads happen at once.

That is why I flip the flow around with an S3 presigned URL. The Lambda function only decides whether and where an upload may happen. S3, which is built for exactly this load, handles the actual transfer. You can roughly estimate how much Lambda runtime this saves with the Lambda cost calculator.

How an S3 presigned URL works

A presigned URL is a regular S3 address with a signature attached. The AWS SDK calculates this signature with the credentials of whoever creates the URL, in our case the IAM role of the Lambda function. The SDK does not need a network call for this, it computes everything locally. During the upload, S3 checks whether signature, bucket, key, method and expiry match, and whether the signing role is allowed to do this at all.

Two properties follow from this, and you need to know both. First, the URL is a bearer token: whoever holds it can use it, as often as they like until it expires. Second, it can never do more than the role that signed it. If your function may only write to eingang/* (German for “inbox”), every URL it signs is limited to that folder as well, no matter how someone tinkers with the URL.

Diagram: browser gets a signed form from Lambda, uploads the file straight to S3, an S3 event starts the check Lambda

Lambda only issues the permission, the file goes straight to S3.

How long does the URL stay valid?

Through the SDKs you can specify a validity of up to seven days. However, that only applies when an IAM user with a long-term access key signs the URL. A Lambda function works with temporary credentials from its execution role, and according to the S3 documentation the URL ends at the latest when those credentials expire, even if you entered a longer time. For uploads this is not a problem, because you only need a few minutes anyway. For download links that should work for days, a presigned URL created in Lambda is the wrong tool.

🎬 Animated flow of an upload with an S3 presigned URL – this animated diagram only runs on my site: watch it on sascha-ms.de

S3 presigned URL with PUT or POST?

There are two variants. With PUT, you sign a PutObject call, and the client sends the file as raw data in the body. With POST, you create a signed form with a policy, and the client sends the file as multipart/form-data. The decisive difference lies in the conditions that S3 enforces on its own.

Property Presigned PUT Presigned POST
Limit the size not via the URL yes, with content-length-range
Fix the Content-Type yes, must match the header exactly yes, as an eq or starts-with condition
Key prefix instead of a fixed key no yes, with starts-with
Client code very simple (fetch with PUT) a bit more (FormData)
Multipart upload for very large files yes, one URL per part no

PUT is simpler, POST lets S3 enforce more rules.

For browser uploads I almost always choose POST. The reason is the size limit: with PUT, anyone holding a valid URL can upload a file of any size, up to the S3 limit for single requests. You only notice afterwards, on the bill or when a processing step chokes on a 3 GB file. With POST, S3 itself rejects files that are too large before storing them. PUT still makes sense for server-to-server transfers and for very large files that you upload in parts via multipart upload.

Step by step: an upload API with the Serverless Framework

The example consists of two Lambda functions and one bucket. The first function issues a signed upload form for a rental. The file first lands in the eingang/ folder. An S3 event starts the second function, which uses the first bytes to check whether an image was really uploaded and then moves the photo to geprueft/ (“checked”). Everything else gets deleted.

Prerequisites

  • Node.js 22 or newer and npm
  • An AWS account with credentials for deployment (for example via AWS IAM Identity Center)
  • Serverless Framework v4 (npm i -g serverless), which bundles TypeScript handlers with esbuild during deployment. Since v4 you need an account with Serverless Inc. for this.
  • Basic knowledge of TypeScript and Lambda

Step 1: Create the project

Create a folder and install the dependencies. For the signed form you need the package @aws-sdk/s3-presigned-post in addition to the S3 client.

mkdir bike-rental-damage-photos && cd bike-rental-damage-photos npm init -y npm i @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner npm i -D typescript @types/node @types/aws-lambda vitest 
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "lib": ["ES2022", "DOM"], "types": ["node"] }, "include": ["src", "test", "web"] } 

Step 2: Define the allowed file types

First, a small file without any AWS dependencies. It defines which image types are allowed and detects the real type of a file by its so-called magic bytes. These are fixed byte sequences at the start of a file: JPEG starts with FF D8 FF, PNG with a fixed eight-byte signature, WebP with “RIFF” and “WEBP”. The Content-Type reported by the browser, on the other hand, is only a claim.

// src/lib/file-types.ts // Allowed image types with file extension and “magic bytes” (the first bytes of every file) export const ERLAUBTE_TYPEN = { "image/jpeg": { endung: "jpg" }, "image/png": { endung: "png" }, "image/webp": { endung: "webp" }, } as const; export type BildTyp = keyof typeof ERLAUBTE_TYPEN; export const MAX_BYTES = 5 * 1024 * 1024; // 5 MB export function istErlaubterTyp(contentType: unknown): contentType is BildTyp { return typeof contentType === "string" && contentType in ERLAUBTE_TYPEN; } // Detects the real file type from the start of the file – regardless of the declared Content-Type export function erkenneTyp(kopf: Uint8Array): BildTyp | undefined { const b = (i: number) => kopf[i]; if (b(0) === 0xff && b(1) === 0xd8 && b(2) === 0xff) return "image/jpeg"; const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; if (png.every((wert, i) => b(i) === wert)) return "image/png"; const ascii = (start: number, ende: number) => String.fromCharCode(...kopf.subarray(start, ende)); if (kopf.length >= 12 && ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") { return "image/webp"; } return undefined; } 

Step 3: The Lambda function for the upload URL

The function checks the rental ID and the requested file type, generates a unique key itself and signs a POST form, which is the form variant of the S3 presigned URL. Two details matter to me here. I never put the file name from the browser into the key, because it can contain path segments, special characters or simply collisions. And the policy enforces size and Content-Type on the S3 side, not just in my code.

// src/upload-url.ts import { randomUUID } from "node:crypto"; import { S3Client } from "@aws-sdk/client-s3"; import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; import { ERLAUBTE_TYPEN, MAX_BYTES, istErlaubterTyp } from "./lib/file-types"; const s3 = new S3Client({}); const BUCKET = process.env.BUCKET_NAME!; const GUELTIG_SEKUNDEN = 300; // 5 minutes are enough for a photo from a phone const antwort = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({ statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); export const handler = async ( event: APIGatewayProxyEventV2, ): Promise<APIGatewayProxyResultV2> => { const ausleihId = event.pathParameters?.ausleihId ?? ""; if (!/^[A-Za-z0-9-]{4,40}$/.test(ausleihId)) { return antwort(400, { fehler: "Invalid rental ID" }); } let contentType: unknown; try { contentType = JSON.parse(event.body ?? "{}").contentType; } catch { return antwort(400, { fehler: "Body is not valid JSON" }); } if (!istErlaubterTyp(contentType)) { return antwort(415, { fehler: "Only JPEG, PNG and WebP are allowed" }); } // The server decides the object key – never take the file name from the client const key = `eingang/${ausleihId}/${randomUUID()}.${ERLAUBTE_TYPEN[contentType].endung}`; const { url, fields } = await createPresignedPost(s3, { Bucket: BUCKET, Key: key, Conditions: [ ["content-length-range", 1, MAX_BYTES], ["eq", "$Content-Type", contentType], ], Fields: { "Content-Type": contentType }, Expires: GUELTIG_SEKUNDEN, }); return antwort(201, { url, fields, key, gueltigSekunden: GUELTIG_SEKUNDEN }); }; 

The response contains the bucket's target URL and a fields object with key, Content-Type, policy and signature. The browser later sends exactly these fields to S3 together with the file. The five-minute validity is deliberately short: the upload starts right after the request, so an intercepted URL quickly becomes worthless.

Step 4: Check the upload afterwards

The POST policy makes sure the Content-Type header is correct. It does not check what is actually inside the file, though. Someone could upload an HTML file with the type image/jpeg. That is why the second function reads only the first 16 bytes with a range request, compares the real type with the declared one and only then moves the photo into the checked area.

// src/check-upload.ts import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import type { S3Event } from "aws-lambda"; import { erkenneTyp } from "./lib/file-types"; const s3 = new S3Client({}); export const handler = async (event: S3Event): Promise<void> => { for (const record of event.Records) { const bucket = record.s3.bucket.name; // Keys arrive URL-encoded (spaces as “+”) const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " ")); if (!key.startsWith("eingang/")) continue; const kopf = await s3.send( new GetObjectCommand({ Bucket: bucket, Key: key, Range: "bytes=0-15" }), ); const bytes = await kopf.Body!.transformToByteArray(); const meta = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); const echterTyp = erkenneTyp(bytes); if (!echterTyp || echterTyp !== meta.ContentType) { console.warn(JSON.stringify({ msg: "Upload rejected", key, echterTyp, angegeben: meta.ContentType })); await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); continue; } const zielKey = key.replace(/^eingang\//, "geprueft/"); await s3.send( new CopyObjectCommand({ Bucket: bucket, Key: zielKey, CopySource: `${bucket}/${encodeURIComponent(key).replace(/%2F/g, "/")}`, }), ); await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); console.log(JSON.stringify({ msg: "Upload checked", zielKey, bytes: meta.ContentLength })); } }; 

Because the S3 event only listens to the eingang/ prefix, the copy to geprueft/ does not trigger a new event. This avoids an endless loop in which the function keeps invoking itself. I decode the key because S3 delivers it URL-encoded in the event. With our UUID keys this does not matter, but with file names containing spaces it very much does.

Step 5: Bucket and functions in serverless.yml

Now you wire everything together. You configure the bucket under provider.s3. The Serverless Framework then creates it and connects it to the S3 event, without you having to write your own notification configuration.

# serverless.yml service: radverleih-schadensfotos frameworkVersion: "4" custom: bucketName: ${self:service}-${sls:stage}-${aws:accountId} erlaubterUrsprung: https://app.radverleih.example provider: name: aws runtime: nodejs22.x architecture: arm64 region: eu-central-1 memorySize: 256 timeout: 10 logRetentionInDays: 14 environment: BUCKET_NAME: ${self:custom.bucketName} httpApi: cors: allowedOrigins: - ${self:custom.erlaubterUrsprung} allowedMethods: [POST] allowedHeaders: [content-type, authorization] s3: # The Serverless Framework creates this bucket and wires up the S3 event  schadensfotos: name: ${self:custom.bucketName} publicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true corsConfiguration: CorsRules: - AllowedOrigins: ["${self:custom.erlaubterUrsprung}"] AllowedMethods: [POST] AllowedHeaders: ["*"] MaxAge: 3000 lifecycleConfiguration: Rules: # Anything not checked disappears after one day  - Id: eingang-aufraeumen Status: Enabled Prefix: eingang/ ExpirationInDays: 1 iam: role: statements: # May only write to the inbox folder – this also signs the upload URL  - Effect: Allow Action: s3:PutObject Resource: arn:aws:s3:::${self:custom.bucketName}/eingang/* # Check function: read and clean up the inbox, store checked photos  - Effect: Allow Action: [s3:GetObject, s3:DeleteObject] Resource: arn:aws:s3:::${self:custom.bucketName}/eingang/* - Effect: Allow Action: s3:PutObject Resource: arn:aws:s3:::${self:custom.bucketName}/geprueft/* functions: uploadUrl: handler: src/upload-url.handler events: - httpApi: method: POST path: /ausleihen/{ausleihId}/schadensfotos pruefeUpload: handler: src/check-upload.handler events: - s3: bucket: schadensfotos event: s3:ObjectCreated:* rules: - prefix: eingang/ 

Take a close look at the IAM permissions. The role may only write to eingang/* and geprueft/*. Even if someone managed to tamper with the policy in the form, S3 would reject a key outside these folders, because the signing role has no permissions there. The bucket's CORS rule allows POST only from your own app domain. Without this rule, the browser blocks the upload even if the signature is correct. The lifecycle rule removes everything in the inbox after one day in case the check ever fails.

Step 6: Test locally with Vitest

Because signing happens locally, you can test the upload function without an AWS account. Dummy credentials are enough, and no request goes out to AWS. The tests check the generated key, the size condition in the policy and the rejection of invalid input.

// test/upload-url.test.ts import { beforeAll, describe, expect, it } from "vitest"; import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from "aws-lambda"; // Signing works locally with dummy credentials, no request goes to AWS beforeAll(() => { process.env.BUCKET_NAME = "radverleih-test"; process.env.AWS_REGION = "eu-central-1"; process.env.AWS_ACCESS_KEY_ID = "AKIDTEST"; process.env.AWS_SECRET_ACCESS_KEY = "secret"; }); const anfrage = (ausleihId: string, body: unknown) => ({ pathParameters: { ausleihId }, body: JSON.stringify(body) }) as unknown as APIGatewayProxyEventV2; describe("upload-url", () => { it("returns URL and form fields for a JPEG", async () => { const { handler } = await import("../src/upload-url"); const res = (await handler(anfrage

原文:https://www.a8q9.cn/en/article/s3-presigned-url-secure-file-uploads-with-lambda-68a71d.html

相关文章