DEV Community

Cover image for S3 presigned URL: secure file uploads with Lambda
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

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
Enter fullscreen mode Exit fullscreen mode
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "lib": ["ES2022", "DOM"],
    "types": ["node"]
  },
  "include": ["src", "test", "web"]
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 });
};
Enter fullscreen mode Exit fullscreen mode

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 }));
  }
};
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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("A-1001", { contentType: "image/jpeg" }))) as APIGatewayProxyStructuredResultV2;
    expect(res.statusCode).toBe(201);
    const body = JSON.parse(res.body as string);
    expect(body.key).toMatch(/^eingang\/A-1001\/[0-9a-f-]{36}\.jpg$/);
    expect(body.fields["Content-Type"]).toBe("image/jpeg");
    const policy = JSON.parse(Buffer.from(body.fields.Policy, "base64").toString());
    expect(policy.conditions).toContainEqual(["content-length-range", 1, 5242880]);
  });

  it("rejects SVG with 415", async () => {
    const { handler } = await import("../src/upload-url");
    const res = (await handler(anfrage("A-1001", { contentType: "image/svg+xml" }))) as APIGatewayProxyStructuredResultV2;
    expect(res.statusCode).toBe(415);
  });

  it("rejects a tampered rental ID", async () => {
    const { handler } = await import("../src/upload-url");
    const res = (await handler(anfrage("../admin", { contentType: "image/png" }))) as APIGatewayProxyStructuredResultV2;
    expect(res.statusCode).toBe(400);
  });
});
Enter fullscreen mode Exit fullscreen mode
// test/file-types.test.ts
import { describe, expect, it } from "vitest";
import { erkenneTyp, istErlaubterTyp } from "../src/lib/file-types";

describe("erkenneTyp", () => {
  it("detects JPEG", () => {
    expect(erkenneTyp(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe("image/jpeg");
  });
  it("detects PNG", () => {
    const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
    expect(erkenneTyp(png)).toBe("image/png");
  });
  it("detects WebP", () => {
    const webp = new TextEncoder().encode("RIFF\u0000\u0000\u0000\u0000WEBPVP8 ");
    expect(erkenneTyp(webp)).toBe("image/webp");
  });
  it("rejects an HTML file disguised as an image", () => {
    expect(erkenneTyp(new TextEncoder().encode("<html><script>"))).toBeUndefined();
  });
});

describe("istErlaubterTyp", () => {
  it("accepts only the three image types", () => {
    expect(istErlaubterTyp("image/png")).toBe(true);
    expect(istErlaubterTyp("image/svg+xml")).toBe(false);
    expect(istErlaubterTyp(42)).toBe(false);
  });
});
Enter fullscreen mode Exit fullscreen mode
npx tsc --noEmit
npx vitest run
Enter fullscreen mode Exit fullscreen mode

Expected result: two test files and eight passing tests. You can find more about testing handlers in my article on Lambda unit tests with Vitest.

Step 7: Deploy and try it with curl

serverless deploy --stage dev
Enter fullscreen mode Exit fullscreen mode

The deployment prints the URL of the HTTP API. Use it to request an upload form and send a test photo straight to S3. With jq you turn the fields into the matching -F parameters for curl.

API="https://abc123.execute-api.eu-central-1.amazonaws.com"
curl -s -X POST "$API/ausleihen/A-1001/schadensfotos" \
  -H "content-type: application/json" \
  -d '{"contentType":"image/jpeg"}' > response.json

# Signed fields as -F parameters, the file comes last
args=()
while IFS= read -r field; do args+=(-F "$field"); done < <(jq -r '.fields | to_entries[] | "\(.key)=\(.value)"' response.json)
curl -s -o /dev/null -w "%{http_code}\n" "${args[@]}" -F "file=@photo.jpg" "$(jq -r .url response.json)"
Enter fullscreen mode Exit fullscreen mode

On success, S3 responds with 204. A few seconds later the photo sits under geprueft/A-1001/…, which you can verify with aws s3 ls s3://<bucket>/geprueft/ --recursive. If you send a file larger than 5 MB, S3 responds with 400 and the error code EntityTooLarge. If you rename a text file to photo.jpg, S3 accepts it, but the check function deletes it and writes a warning to the log.

Step 8: Upload from the browser

In the frontend it only takes a few lines. The order of the form fields matters, though: S3 expects the file as the last field and ignores anything that comes after it.

// web/upload.ts
// Runs in the browser: fetches the signed form fields and sends the file straight to S3
export async function ladeSchadensfotoHoch(
  apiBasis: string,
  ausleihId: string,
  datei: File,
): Promise<string> {
  const antwort = await fetch(`${apiBasis}/ausleihen/${ausleihId}/schadensfotos`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ contentType: datei.type }),
  });
  if (!antwort.ok) throw new Error(`Upload URL rejected: ${antwort.status}`);

  const { url, fields, key } = (await antwort.json()) as {
    url: string;
    fields: Record<string, string>;
    key: string;
  };

  const formular = new FormData();
  for (const [name, wert] of Object.entries(fields)) formular.append(name, wert);
  formular.append("file", datei); // must be the last field

  const upload = await fetch(url, { method: "POST", body: formular });
  // On success, S3 responds with 204 No Content by default
  if (upload.status !== 204) {
    throw new Error(`Upload failed: ${upload.status} ${await upload.text()}`);
  }
  return key;
}
Enter fullscreen mode Exit fullscreen mode

Common S3 presigned URL errors

PUT upload fails after an SDK update

This is the error that currently catches most people. Since version 3.729.0, the AWS SDK for JavaScript calculates a CRC32 checksum for uploads by default. When you sign a PUT URL, however, there is no file yet, so the checksum of an empty body ends up in the URL. You can easily reproduce this with the current SDK version: without further configuration, the signed URL contains the parameters x-amz-checksum-crc32 and x-amz-sdk-checksum-algorithm, and S3 then rejects the real file because the checksum does not match. The fix is a client setting:

// src/put-url.ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

// Without this setting, the SDK also signs a CRC32 checksum of the empty body
const s3 = new S3Client({ requestChecksumCalculation: "WHEN_REQUIRED" });

export const erzeugePutUrl = (bucket: string, key: string): Promise<string> =>
  getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: "image/jpeg" }),
    { expiresIn: 300 },
  );
Enter fullscreen mode Exit fullscreen mode

With WHEN_REQUIRED, both parameters disappear from the URL. The presigned POST form from this article is not affected.

SignatureDoesNotMatch or 403 Forbidden

With PUT, the Content-Type is part of the signature. If you specified ContentType: "image/jpeg" when signing, the client must send exactly this header. A missing header or image/jpg instead of image/jpeg is enough for a 403. With POST, the typical cause is different: a field from fields is missing, was renamed or comes after the file.

CORS errors in the browser

The API and the bucket each need their own CORS rules. The HTTP API rule only covers the request for the form, while the upload goes to a different domain, namely S3. If the bucket rule is missing, you see a CORS error in the console even though curl works perfectly.

URL expires earlier than expected

If you create URLs in advance and store them, they can become invalid before the configured time because the temporary credentials of the Lambda role expire. So always create upload URLs fresh, right before the client needs them.

Keeping an eye on security and cost

In the example, the upload API is open so you can try it easily. In a real application, an authorizer belongs in front of it, such as a JWT authorizer on the HTTP API with Amazon Cognito. The function should also check whether the signed-in person is actually returning that rental. Otherwise anyone can upload photos for other people's rentals.

In addition, you can reject old signatures at bucket level so that an S3 presigned URL does not live longer than planned. The s3:signatureAge condition in a bucket policy denies requests whose signature is older than a defined value in milliseconds. That is a second safeguard in case a long validity sneaks into the code by accident. Also, never serve uploaded files from the same domain as your app, and certainly not unchecked. That is exactly why the example separates eingang/ and geprueft/.

The cost of uploading via an S3 presigned URL is modest. Per upload you pay for a Lambda invocation of a few milliseconds for signing, a short check invocation, a handful of S3 requests (PUT, GET, COPY, DELETE) and the storage. The expensive part of the naive approach, Lambda runtime during the transfer, disappears completely. According to the AWS price list, data transferred into S3 from the internet is free.

My take

I consider the S3 presigned URL one of the patterns that show Serverless thinking well. Instead of funnelling data through your own function, you give a managed service a tightly scoped permission and let it do the heavy lifting. Your function gets smaller, faster and cheaper, and S3 scales the uploads without you having to think about it.

I disagree with the common attitude that the signed URL settles the security question. The signature only protects the way into the bucket. What happens to the file afterwards, whether it really is an image, who may see it and how long it stays there, is up to you. For me, the check function is therefore a fixed part of the pattern and not something for “we'll do it later”. If the uploaded files are processed further, take a look at the claim check pattern with S3 and SQS, which also sends large payloads via S3 instead of through the queue.

How to automatically check uploaded files for malware before they are processed further is what I show in the article S3 malware scanning: check uploads with GuardDuty.

FAQ

How long is an S3 presigned URL valid?

Via the AWS CLI and SDKs up to seven days, if an IAM user with a long-term key signs it. If a role with temporary credentials signs it, such as a Lambda function, the URL ends at the latest when those credentials expire.

Can I limit the file size with a presigned URL?

Not with a PUT URL. With a presigned POST form you can: the content-length-range condition in the policy sets a minimum and maximum size, and S3 rejects everything outside of it.

Does the client need AWS credentials?

No. The signature in the URL or form replaces the credentials. The client only needs your API's response and a normal HTTP connection to S3.

Can a presigned URL be used more than once?

Yes, as often as you like until it expires. With a fixed key, every further upload overwrites the previous file. So keep the validity short and create a new key per upload.

Your next step

Look through your projects for places where files are funnelled through a Lambda function or a container. If you find one, it is a good candidate for switching to an S3 presigned URL: write the upload function, create the bucket with CORS and a lifecycle rule, add the check function. With the code from the repository you have a working prototype within an hour. And I am curious: do you check uploaded files after the upload today, or do you trust the Content-Type? Let me know in the comments.

How the files get back to users safely, without making the bucket public, is covered in my article on CloudFront signed URLs for private downloads.

All code from this article, ready to run: smirowstanitzok/sascha-ms-examples

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)