LearnWithShobhan

S3 presigned URLs in TypeScript: upload large files straight from the browser

Shobhan Biswas··5 min read
>_From the course VidShare: 109 lessons, lifetime access.

When users upload a video, the first instinct is to POST the file to your API and let the server push it to S3. That works for a profile picture. It falls apart for a 2 GB video: API Gateway caps request payloads at 10 MB, Lambda has its own limits, and you pay to move every byte twice.

The fix is a presigned URL. Your backend signs a short-lived URL that allows exactly one action on one object, and the browser uploads directly to S3. Your API never touches the bytes. It's how uploads work in the VidShare video platform.

First, three things about S3

S3 (Simple Storage Service, three S's) stores files in buckets.

S3 bucket facts: bucket name must be unique globally, object storage, there is no directory or folder
Three facts that explain most S3 surprises.
  1. Bucket names are globally unique. Like a domain name: across every AWS account and region, only one bucket can be called vidshare-upload.
  2. It's object storage. A JPEG, a TXT and an MP4 are all just binary objects to S3.
  3. There are no real folders. The console shows a pet folder, but that's only a name prefix: the object key is literally pet/cat.png.
Objects named pet/cat.png, pet/dog.png and car.jpg in one flat bucket
Folders are an illusion made of slashes in object keys.

Every object has an S3 URI (s3://bucket/key, used by AWS services) and an object URL (https://bucket.s3.amazonaws.com/key). Open the object URL of a private bucket and you get AccessDenied. That's where presigned URLs come in.

What a presigned URL is

In the S3 console you can pick any object and choose Share with a presigned URL, valid for, say, 10 minutes. The URL is the normal object URL plus a signature and an expiry in the query string. Anyone with it can download that one object until it expires, even though the bucket stays private.

The same works for uploads: sign a PutObject and a client can upload one specific key, once, within the time window. Here's the flow:

  1. 1

    Client asks your API

    "I want to upload a video"

  2. 2

    Lambda signs a URL

    PutObject, one key, 10 min

  3. 3

    Browser PUTs the file

    straight to S3

  4. 4

    S3 event fires

    processing starts

Generate the URL with AWS SDK v3

Install the client and the presigner:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

This is the handler from the course, exactly as built in the lesson:

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
export const handler = async ({ bucketRegion, bucketName, key }: {
  bucketName: string;
  key: string;
  bucketRegion: string;
}) => {
  const client = new S3Client({ region: bucketRegion });
 
  const putCommand = new PutObjectCommand({
    Bucket: bucketName,
    Key: key,
  });
 
  const url = await getSignedUrl(client, putCommand, {
    expiresIn: 60 * 10, // seconds: valid for 10 minutes
  });
 
  return { url };
};
VS Code showing PutObjectCommand and getSignedUrl with expiresIn 60 * 10
Built from an empty folder in the lesson, then deployed as a Lambda.

getSignedUrl works with any command. Pass a GetObjectCommand instead and you get a download link.

Free to watch. Get all 109 lessons →

The 403 everyone hits

Call the Lambda, copy the URL, PUT a file to it from Postman... and get 403 AccessDenied.

Here's why: signing happens offline. The SDK will happily sign a URL with credentials that aren't allowed to do the thing. The URL only carries the permissions of whoever signed it. If the Lambda's role can't PutObject, neither can the URL.

Add an inline policy to the Lambda's execution role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::vidshare-upload-bucket/*"
    }
  ]
}

Generate a fresh URL, send the PUT again: 200 OK, and the file appears in the bucket.

Upload from the browser

No SDK needed on the client, just a PUT:

async function upload(file: File) {
  const res = await fetch("/api/videos", { method: "PUT" });
  const { url } = await res.json();
 
  const put = await fetch(url, { method: "PUT", body: file });
  if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
}

From a browser (unlike Postman), the bucket also needs a CORS rule that allows PUT from your site:

[
  {
    "AllowedOrigins": ["https://your-site.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["*"]
  }
]

Production checklist

  • Generate the key on the server (uploads/<videoId>.mp4). Never let the client choose it, or users can overwrite each other's files.
  • Keep expiresIn short. It only has to last until the upload starts.
  • Create the S3 client outside the handler so Lambda reuses it across invocations.
  • React to the upload with an S3 event, not a client callback. The client might never call back.

Common errors

ErrorUsual cause
403 AccessDeniedThe signing role lacks s3:PutObject on bucket/*
403 SignatureDoesNotMatchThe request differs from what was signed: a Content-Type you signed but didn't send, or a different region
403 Request has expiredThe URL's expiresIn passed
CORS error in the browser consoleNo CORS rule for your origin and the PUT method

Key takeaways

  • ✓Bucket names are global, objects are just bytes, and folders are key prefixes.
  • ✓A presigned URL grants one action on one key for a limited time.
  • ✓The URL only has the permissions of the role that signed it: give it s3:PutObject on bucket/*.
  • ✓Browsers also need a CORS rule on the bucket.

Build the full upload pipeline

Presigned uploads are step one of VidShare, a 109-lesson course where you build a complete serverless video platform on AWS: upload, S3 events, MediaInfo metadata, MediaConvert transcoding, EventBridge, DynamoDB and OpenSearch, all in TypeScript with tests and CDK. This exact lesson is free to watch.

Free download

Linux command line cheat sheet

One printable page: navigation, files, permissions, pipes, processes, scripts and SSH.

Occasional emails with new tutorials and courses. Unsubscribe any time.

Keep reading